Skip to content

Commit 494b473

Browse files
committed
Multi-listener OrderBook + Stop / Stop-Limit orders
Reworks the OrderBook callback model so the matching engine, the feed publisher, and any other subscribers can attach at the same time without one clobbering the other. add_trade_listener / add_order_listener fan out to a vector; the legacy set_*_callback setters now alias to the same path so existing code keeps compiling. Adds Stop and Stop-Limit order types. Parked stops live in dedicated per-side multimaps keyed by trigger price. Each match cycle updates last_trade_price and runs a guarded check_stop_triggers() that releases stops whose trigger has been crossed and re-submits them as Market (Stop) or Limit (StopLimit) orders. Cancellation works on parked stops too. Re-entry is guarded so a cascade does not blow the stack. Other changes: - FeedPublisher is re-enabled in main.cpp now that attach() does not overwrite the engine's trade callback. Per-run report shows feed message counts. - Kyle's lambda regression and the spread decomposition mid-after lookup are now timestamp-based, using the Hawkes event clock. - bench/bench_latency.cpp: per-op latency histogram (p50/p90/p99/p999), referenced in the README but previously missing. - CTest registration: invariants suite + a tiny end-to-end smoke test. - .github/workflows/ci.yml: build Debug + Release on Linux and macOS, run ctest, and run the bench targets on Release. - Fixed a pre-existing UB in match_against where std::prev(end()) was being computed before checking that the side was non-empty (caught by AddressSanitizer on the new Debug build). - Four new tests covering stop triggers, stop cancel, stop-limit resting, and the multi-listener fan-out.
1 parent 90a6310 commit 494b473

11 files changed

Lines changed: 707 additions & 67 deletions

File tree

.github/workflows/ci.yml

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
name: CI
2+
3+
on:
4+
push:
5+
branches: [ main ]
6+
pull_request:
7+
branches: [ main ]
8+
9+
jobs:
10+
build-and-test:
11+
name: ${{ matrix.os }} / ${{ matrix.build_type }}
12+
runs-on: ${{ matrix.os }}
13+
strategy:
14+
fail-fast: false
15+
matrix:
16+
os: [ubuntu-latest, macos-latest]
17+
build_type: [Release, Debug]
18+
19+
steps:
20+
- uses: actions/checkout@v4
21+
22+
- name: Configure
23+
run: cmake -S . -B build -DCMAKE_BUILD_TYPE=${{ matrix.build_type }}
24+
25+
- name: Build
26+
run: cmake --build build --parallel
27+
28+
- name: Run tests (ctest)
29+
working-directory: build
30+
run: ctest --output-on-failure
31+
32+
- name: Quick benchmarks
33+
if: matrix.build_type == 'Release'
34+
working-directory: build
35+
run: |
36+
./bin/bench_throughput || true
37+
./bin/bench_latency --ops 200000 --warmup 20000 || true

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
# Build
22
build/
3+
build-*/
34
cmake-build-*/
45
*.o
56
*.a

CHANGELOG.md

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,54 @@
11
# Changelog
22

3+
## v1.1.0 (2026-04-06)
4+
5+
### New features
6+
- **Stop and Stop-Limit orders.** A parked-order book per side, indexed by
7+
trigger price. The book tracks `last_trade_price` and on every aggressive
8+
cycle it walks the parked orders whose trigger has been crossed and
9+
re-submits them as Market (Stop) or Limit (StopLimit) orders. Cancellation
10+
works against parked stops too. Re-entry is guarded so a stop that
11+
triggers another stop doesn't blow the stack.
12+
- **Multi-subscriber callback fan-out on `OrderBook`.** New
13+
`add_trade_listener` / `add_order_listener` APIs let the matching engine,
14+
the feed publisher, the analytics layer and any user code subscribe to
15+
the same book without clobbering each other. The legacy `set_*_callback`
16+
setters now alias to `add_*_listener` so existing code keeps working.
17+
- **`FeedPublisher` re-enabled in the main simulation path.** Previously
18+
disabled because it would replace the engine's trade callback. Now wired
19+
through the listener fan-out and reports `Feed messages: ...` in the
20+
per-run report.
21+
- **`bench_latency` benchmark binary.** Reports per-operation latency
22+
histogram (min/p50/p90/p95/p99/p999/max) plus end-to-end throughput.
23+
- **CTest integration.** `enable_testing()` and two registered tests:
24+
the invariant suite and a tiny end-to-end smoke test of the simulator.
25+
- **GitHub Actions CI.** Builds Debug and Release on Ubuntu and macOS,
26+
runs `ctest`, and on Release builds also runs the throughput and
27+
latency benches.
28+
29+
### Fixes
30+
- **Undefined behaviour in `match_against`.** The function was computing
31+
`std::prev(contra_side.end())` *before* checking whether the side was
32+
empty, which is UB on an empty `std::map`. Caught by AddressSanitizer
33+
on a Debug build. The iterator is now computed inside the loop, after
34+
the empty check.
35+
- Kyle's lambda regression now uses simulated wall-clock timestamps from
36+
the Hawkes event stream instead of an event-index proxy. Spread
37+
decomposition's mid-after lookup is also time-based now.
38+
- Removed the `unused variable` warning in the cancel test.
39+
40+
### Tests
41+
- Four new test cases for the changes above:
42+
`test_stop_market_triggers`, `test_stop_limit_triggers_and_rests`,
43+
`test_stop_cancel`, `test_multi_listener_fanout`.
44+
45+
### Still on the to-do list
46+
- Iceberg / hidden-quantity orders.
47+
- Per-agent order tracking in the simulator (cancel rates are still
48+
estimates rather than ground truth).
49+
- Volatility clustering remains weak; the ZI agents need to condition on
50+
recent volatility before this will improve materially.
51+
352
## v1.0.0 (2026-02-12)
453

554
First public release.

CMakeLists.txt

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,24 @@ add_executable(test_invariants core/tests/test_invariants.cpp)
3434

3535
# benchmarks
3636
add_executable(bench_throughput bench/bench_throughput.cpp)
37+
add_executable(bench_latency bench/bench_latency.cpp)
38+
39+
# CTest registration — `ctest` from the build dir runs the full suite.
40+
enable_testing()
41+
add_test(NAME invariants COMMAND test_invariants)
42+
set_tests_properties(invariants PROPERTIES
43+
PASS_REGULAR_EXPRESSION "ALL TESTS PASSED"
44+
TIMEOUT 60
45+
)
46+
47+
# Smoke test: a tiny end-to-end run of the main simulator. Catches the
48+
# kind of regression where the binary builds but immediately segfaults.
49+
add_test(
50+
NAME simulator_smoke
51+
COMMAND micro_exchange --duration 5 --output ${CMAKE_BINARY_DIR}/smoke_out
52+
)
53+
set_tests_properties(simulator_smoke PROPERTIES TIMEOUT 60)
3754

38-
install(TARGETS micro_exchange test_invariants bench_throughput
55+
install(TARGETS micro_exchange test_invariants bench_throughput bench_latency
3956
RUNTIME DESTINATION bin
4057
)

README.md

Lines changed: 41 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
# MicroExchange
22

3+
[![CI](https://github.com/Leotaby/MicroExchange/actions/workflows/ci.yml/badge.svg)](https://github.com/Leotaby/MicroExchange/actions/workflows/ci.yml)
4+
![C++20](https://img.shields.io/badge/C%2B%2B-20-blue.svg)
5+
![License: MIT](https://img.shields.io/badge/license-MIT-green.svg)
6+
37
**Exchange-grade CLOB matching engine + ITCH-style market data replay + microstructure analytics in modern C++20.**
48

59
> **[📊 Live Interactive Dashboard](https://Leotaby.github.io/MicroExchange/)** — 3D order book surface, Kyle's lambda landscape, spread decomposition, stylized facts.
@@ -38,10 +42,10 @@ A complete market microstructure laboratory: from order entry to trade print, fr
3842
│ │ ZI agents) │ │ │ │ │ │
3943
│ └──────────────┘ │ • Limit/Market │ │ • Incremental │ │
4044
│ │ • IOC / FOK │ │ • Snapshots │ │
41-
│ ┌──────────────┐ │ • Amend/Cancel │ │ • Trade prints │ │
42-
│ │ ITCH Replay │───▶│ • Partial fills │ └────────┬──────────┘ │
43-
│ │ (historical │ └──────────────────┘ │ │
44-
│ │ data) │ ▼ │
45+
│ ┌──────────────┐ │ • Stop/StopLim │ │ • Trade prints │ │
46+
│ │ ITCH Replay │───▶│ • Amend/Cancel │ └────────┬──────────┘ │
47+
│ │ (historical │ │ • Partial fills │ │ │
48+
│ │ data) │ └──────────────────┘ ▼ │
4549
│ └──────────────┘ ┌────────────────────┐│
4650
│ │ Analytics ││
4751
│ │ • Spread decomp ││
@@ -81,6 +85,22 @@ Left: return distribution vs Gaussian — heavy tails from Hawkes-driven cluster
8185

8286
---
8387

88+
## Order Types
89+
90+
| Type | TIF | Behaviour |
91+
|---|---|---|
92+
| **Limit** | GTC / DAY | Rests on the book at `price`. |
93+
| **Market** | IOC | Crosses the book at any price; unfilled remainder cancelled. |
94+
| **IOC** | IOC | Limit semantics; remainder after the first match is cancelled. |
95+
| **FOK** | FOK | Pre-checked for full fill; if not, never enters the book. |
96+
| **Stop** || Parked until `last_trade_price` crosses `stop_price`, then released as Market. |
97+
| **StopLimit** || Parked until trigger; released as Limit at `price`. |
98+
99+
Stops are stored in dedicated per-side multimaps keyed by trigger price.
100+
Every aggressive cycle that updates the last print runs a guarded
101+
`check_stop_triggers()` pass — releases are themselves matched immediately,
102+
which can cascade into more triggers without recursing on the call stack.
103+
84104
## Microstructure Concepts Implemented
85105

86106
| Domain | Concept | Implementation |
@@ -115,14 +135,16 @@ Most GitHub "matching engines" are toy implementations — a sorted map, a match
115135

116136
- **Volatility clustering is weak**: The AC(|r|) at lag 1 is ~0.02, well below the empirical 0.15-0.40 range. The Hawkes process generates clustered *arrivals* but the ZI agents don't modulate aggressiveness with volatility. A regime-switching model or agents that condition on recent returns would help.
117137

118-
- **Kyle's lambda R² is near zero**: The midprice indexing uses event count rather than wall clock time, so the interval bucketing doesn't align properly. Needs timestamp-based aggregation.
119-
120-
- **FeedPublisher overwrites OrderBook callbacks**: The `attach()` method calls `book.set_trade_callback()` which clobbers the engine's internal routing. Needs a multi-subscriber pattern (vector of callbacks, or an event bus). Disabled in main.cpp for now.
121-
122138
- **Arena allocator never frees**: Orders accumulate in the arena for the lifetime of the process. Fine for simulation (it exits) but would need periodic cleanup or epoch-based reclamation for production.
123139

124140
- **No proper order tracking per agent**: The cancellation logic in the simulator is approximate — agents don't track their own outstanding orders, so cancel rates are estimates.
125141

142+
- **No iceberg / hidden-quantity orders yet.** Refilling visible slices interacts with FIFO priority in a non-obvious way; tracked in `CHANGELOG.md` as future work.
143+
144+
### Resolved in v1.1.0
145+
- ~~FeedPublisher overwrites OrderBook callbacks~~ — fixed by a multi-subscriber listener fan-out on `OrderBook`. The publisher is now re-enabled in `main.cpp` and reports message counts in the per-run report.
146+
- ~~Kyle's lambda R² is near zero because of event-index bucketing~~ — the regression now uses Hawkes wall-clock timestamps for both trades and midprices. (R² is still data-quality-limited under default ZI parameters, but the bucketing is no longer the bottleneck.)
147+
126148
---
127149

128150
## Build
@@ -155,10 +177,14 @@ g++ -std=c++20 -O2 -I core/include -I md/include -I sim/include -I analytics/inc
155177

156178
### Run Tests & Benchmarks
157179
```bash
158-
./bin/test_matching_engine # Property-based invariant tests
159-
./bin/test_fuzz_orders # Fuzz random order sequences
180+
# Full CTest suite (invariants + fuzz + end-to-end smoke run)
181+
cd build && ctest --output-on-failure
182+
183+
# Or invoke binaries directly
184+
./bin/test_invariants # Property-based + fuzz + stop-order tests
160185
./bin/bench_throughput # Single-thread matching throughput
161-
./bin/bench_latency # Latency histogram (p50/p95/p99/p999)
186+
./bin/bench_latency # Latency histogram (p50/p90/p95/p99/p99.9)
187+
./bin/bench_latency --ops 5000000 # ...with a longer run
162188
```
163189

164190
---
@@ -253,7 +279,10 @@ MicroExchange/
253279
├── src/
254280
│ └── main.cpp # CLI entry point
255281
├── bench/
256-
│ └── bench_throughput.cpp # Performance benchmarks
282+
│ ├── bench_throughput.cpp # Single-thread matching throughput
283+
│ └── bench_latency.cpp # Per-op latency histogram (p50/p90/p99/...)
284+
├── .github/workflows/
285+
│ └── ci.yml # GitHub Actions: build + ctest on Linux/macOS
257286
├── research/
258287
│ └── microstructure_paper.md # Theory + empirical writeup
259288
├── output/ # Generated by simulation

bench/bench_latency.cpp

Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
/*
2+
* bench_latency.cpp - per-operation latency histogram for the matching engine.
3+
*
4+
* Measures the wall-clock cost of submit_order() across a long stream of
5+
* synthetic add/cancel/match operations and reports p50/p90/p95/p99/p999
6+
* along with min/max. The intent is to give a quick "is anything regressing"
7+
* signal that can be wired into CI or run by hand before tagging a release.
8+
*
9+
* Usage:
10+
* ./bench_latency # 1M operations against a 10x5 seeded book
11+
* ./bench_latency --ops 5000000 # 5M operations
12+
* ./bench_latency --warmup 200000 # warmup count before timing starts
13+
*/
14+
15+
#include "MatchingEngine.h"
16+
#include "OrderBook.h"
17+
#include "Order.h"
18+
19+
#include <algorithm>
20+
#include <chrono>
21+
#include <cstdint>
22+
#include <cstring>
23+
#include <iomanip>
24+
#include <iostream>
25+
#include <random>
26+
#include <string>
27+
#include <vector>
28+
29+
using namespace micro_exchange::core;
30+
31+
namespace {
32+
33+
struct CliArgs {
34+
size_t ops = 1'000'000;
35+
size_t warmup = 100'000;
36+
};
37+
38+
CliArgs parse(int argc, char** argv) {
39+
CliArgs a;
40+
for (int i = 1; i < argc; ++i) {
41+
std::string s = argv[i];
42+
if (s == "--ops" && i + 1 < argc) a.ops = std::stoull(argv[++i]);
43+
else if (s == "--warmup" && i + 1 < argc) a.warmup = std::stoull(argv[++i]);
44+
else if (s == "--help") {
45+
std::cout << "usage: bench_latency [--ops N] [--warmup N]\n";
46+
std::exit(0);
47+
}
48+
}
49+
return a;
50+
}
51+
52+
void seed_book(MatchingEngine& engine, const char* sym, Price mid) {
53+
OrderId id = 1;
54+
for (int lvl = 1; lvl <= 10; ++lvl) {
55+
for (int j = 0; j < 5; ++j) {
56+
NewOrderRequest req{};
57+
req.id = id++;
58+
req.side = Side::Buy;
59+
req.type = OrderType::Limit;
60+
req.tif = TimeInForce::GTC;
61+
req.price = mid - lvl;
62+
req.quantity = 100 + j * 50;
63+
std::strncpy(req.symbol, sym, 15);
64+
engine.submit_order(req);
65+
66+
req.id = id++;
67+
req.side = Side::Sell;
68+
req.price = mid + lvl;
69+
engine.submit_order(req);
70+
}
71+
}
72+
}
73+
74+
double percentile(std::vector<uint64_t>& v, double p) {
75+
if (v.empty()) return 0;
76+
size_t idx = static_cast<size_t>(p * (v.size() - 1));
77+
std::nth_element(v.begin(), v.begin() + idx, v.end());
78+
return static_cast<double>(v[idx]);
79+
}
80+
81+
} // namespace
82+
83+
int main(int argc, char** argv) {
84+
CliArgs args = parse(argc, argv);
85+
86+
std::cout << "\n MicroExchange — Latency Benchmark\n";
87+
std::cout << " ─────────────────────────────────\n";
88+
std::cout << " Operations: " << args.ops << "\n";
89+
std::cout << " Warmup: " << args.warmup << "\n\n";
90+
91+
const char* sym = "BENCH";
92+
MatchingEngine engine;
93+
engine.add_symbol(sym);
94+
seed_book(engine, sym, 10000);
95+
96+
std::mt19937_64 rng(0xBEEFCAFE);
97+
std::uniform_int_distribution<int> side_dist(0, 1);
98+
std::uniform_int_distribution<int> type_dist(0, 9);
99+
std::uniform_int_distribution<Price> price_dist(9990, 10010);
100+
std::uniform_int_distribution<Quantity> qty_dist(1, 5);
101+
102+
OrderId id = 100'000;
103+
auto submit_random = [&]() {
104+
NewOrderRequest req{};
105+
req.id = id++;
106+
req.side = side_dist(rng) ? Side::Buy : Side::Sell;
107+
bool is_market = type_dist(rng) == 0; // 10% market
108+
req.type = is_market ? OrderType::Market : OrderType::Limit;
109+
req.tif = is_market ? TimeInForce::IOC : TimeInForce::GTC;
110+
req.price = is_market ? PRICE_MARKET : price_dist(rng);
111+
req.quantity = qty_dist(rng) * 100;
112+
std::strncpy(req.symbol, sym, 15);
113+
engine.submit_order(req);
114+
};
115+
116+
// Warmup
117+
for (size_t i = 0; i < args.warmup; ++i) submit_random();
118+
119+
std::vector<uint64_t> latencies;
120+
latencies.reserve(args.ops);
121+
122+
auto t_start = std::chrono::steady_clock::now();
123+
for (size_t i = 0; i < args.ops; ++i) {
124+
auto a = std::chrono::steady_clock::now();
125+
submit_random();
126+
auto b = std::chrono::steady_clock::now();
127+
latencies.push_back(
128+
std::chrono::duration_cast<std::chrono::nanoseconds>(b - a).count());
129+
}
130+
auto t_end = std::chrono::steady_clock::now();
131+
132+
double wall_sec =
133+
std::chrono::duration<double>(t_end - t_start).count();
134+
135+
double p50 = percentile(latencies, 0.50);
136+
double p90 = percentile(latencies, 0.90);
137+
double p95 = percentile(latencies, 0.95);
138+
double p99 = percentile(latencies, 0.99);
139+
double p999 = percentile(latencies, 0.999);
140+
auto mm = std::minmax_element(latencies.begin(), latencies.end());
141+
142+
std::cout << std::fixed << std::setprecision(0);
143+
std::cout << " Throughput: " << (args.ops / wall_sec) << " ops/sec\n\n";
144+
145+
std::cout << " Latency (nanoseconds)\n";
146+
std::cout << " ─────────────────────\n";
147+
std::cout << " min " << *mm.first << "\n";
148+
std::cout << " p50 " << p50 << "\n";
149+
std::cout << " p90 " << p90 << "\n";
150+
std::cout << " p95 " << p95 << "\n";
151+
std::cout << " p99 " << p99 << "\n";
152+
std::cout << " p99.9 " << p999 << "\n";
153+
std::cout << " max " << *mm.second << "\n\n";
154+
155+
return 0;
156+
}

0 commit comments

Comments
 (0)