Skip to content

Commit 906842f

Browse files
committed
v1.5.0: capture event timestamp once per order (+~30% throughput); architecture diagram
1 parent aac067b commit 906842f

6 files changed

Lines changed: 74 additions & 46 deletions

File tree

CHANGELOG.md

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

3+
## v1.5.0 (2026-05-30)
4+
5+
### Performance
6+
- **Capture the event timestamp once per order instead of once per fill.** Both
7+
`OrderBook` and `ArrayOrderBook` were calling `steady_clock::now()` (~17 ns)
8+
for the order's entry plus every trade print and both sides of every fill —
9+
~4 clock reads per order, which profiling put at ~40% of hot-path time. The
10+
engine now reads the clock once per inbound event and threads it through
11+
matching via `Order::fill(qty, ts)` / `Order::cancel(ts)`. Measured on x86
12+
(1M orders): `std::map` 5.9M→7.6M orders/sec (**+28%**), `ArrayOrderBook`
13+
6.7M→8.8M (**+30%**). Trade streams are unchanged — the `orderbook_equivalence`
14+
and `gateway_e2e` CI gates still pass.
15+
16+
### Docs
17+
- README architecture diagram now shows the TCP gateway and the two book
18+
backends feeding the matching engine.
19+
320
## v1.4.0 (2026-05-30)
421

522
### New features

CMakeLists.txt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
cmake_minimum_required(VERSION 3.20)
22
project(MicroExchange
3-
VERSION 1.4.0
3+
VERSION 1.5.0
44
LANGUAGES CXX
55
DESCRIPTION "Exchange-grade CLOB matching engine + microstructure analytics"
66
)

README.md

Lines changed: 22 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -33,28 +33,24 @@ A complete market microstructure laboratory: from order entry to trade print, fr
3333
## Architecture
3434

3535
```
36-
┌──────────────────────────────────────────────────────────────────────┐
37-
│ MicroExchange Architecture │
38-
│ │
39-
│ ┌──────────────┐ ┌──────────────────┐ ┌──────────────────┐ │
40-
│ │ Simulation │───▶│ Matching Engine │───▶│ Market Data Feed │ │
41-
│ │ (Hawkes / │ │ (CLOB + FIFO) │ │ (ITCH-style) │ │
42-
│ │ ZI agents) │ │ │ │ │ │
43-
│ └──────────────┘ │ • Limit/Market │ │ • Incremental │ │
44-
│ │ • IOC / FOK │ │ • Snapshots │ │
45-
│ ┌──────────────┐ │ • Stop/StopLim │ │ • Trade prints │ │
46-
│ │ ITCH Replay │───▶│ • Amend/Cancel │ └────────┬──────────┘ │
47-
│ │ (historical │ │ • Partial fills │ │ │
48-
│ │ data) │ └──────────────────┘ ▼ │
49-
│ └──────────────┘ ┌────────────────────┐│
50-
│ │ Analytics ││
51-
│ │ • Spread decomp ││
52-
│ │ • Price impact ││
53-
│ │ • Kyle's λ ││
54-
│ │ • Order imbalance ││
55-
│ │ • Stylized facts ││
56-
│ └────────────────────┘│
57-
└──────────────────────────────────────────────────────────────────────┘
36+
order sources matching core outputs
37+
───────────── ───────────── ───────
38+
39+
┌──────────────┐
40+
│ TCP Gateway │─┐ binary order-entry protocol over a socket (net/)
41+
└──────────────┘ │
42+
┌──────────────┐ │ ┌────────────────────────────┐ ┌──────────────────┐
43+
│ Simulation │ ├──▶│ Matching Engine │──▶ │ Market Data Feed │
44+
│ (Hawkes/ZI) │ │ │ price-time priority (FIFO) │ │ (ITCH-style: │
45+
└──────────────┘ │ │ Limit/Market/IOC/FOK/Stop │ │ incremental + │
46+
┌──────────────┐ │ │ │ │ snapshots) │
47+
│ ITCH Replay │─┘ │ book backends (same API): │ └──────────────────┘
48+
│ (historical) │ │ • OrderBook (std::map) │ ┌──────────────────┐
49+
└──────────────┘ │ • ArrayOrderBook (array + │──▶ │ Analytics │
50+
│ bitmap BBO index) │ │ spread decomp, │
51+
└────────────────────────────┘ │ Kyle's λ, OFI, │
52+
│ stylized facts │
53+
└──────────────────┘
5854
```
5955

6056
---
@@ -241,7 +237,10 @@ Two takeaways that matter more than a single headline number:
241237
is ~even but median latency drops ~33% (84 ns vs 125 ns). Either way the
242238
array is competitive-or-faster — and the ceiling is set by the per-order
243239
`OrderId→Order*` hash insert and the `now()` timestamp, *not* the level
244-
container, so trimming `now()` off the hot path is the next optimization.
240+
container. **v1.5.0 acts on exactly this:** capturing the timestamp once per
241+
order instead of once per fill raised both books ~28–30% on x86 (`std::map`
242+
5.9M→7.6M/s, array 6.7M→8.8M/s); the `OrderId` hash insert is now the
243+
dominant remaining per-order cost.
245244
2. **A flat array needs an index.** A naive linear best-bid/ask scan is ~25×
246245
*slower* than `std::map` on a wide/sparse book because it walks empty levels;
247246
the bitmap occupied-index fixes that and keeps the array ≥1.0× through

core/include/ArrayOrderBook.h

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,7 @@ class ArrayOrderBook {
8686
// ═══════════════════════════════════════════
8787

8888
Order* add_order(const NewOrderRequest& req) {
89+
ts_ = now(); // one clock read per event; reused for every fill
8990
Order* order = order_arena_.allocate();
9091
new (order) Order{};
9192

@@ -99,16 +100,16 @@ class ArrayOrderBook {
99100
order->quantity = req.quantity;
100101
order->leaves_qty = req.quantity;
101102
order->filled_qty = 0;
102-
order->entry_time = now();
103-
order->last_update = order->entry_time;
103+
order->entry_time = ts_;
104+
order->last_update = ts_;
104105
order->status = OrderStatus::New;
105106
std::memcpy(order->symbol, req.symbol, sizeof(order->symbol));
106107

107108
order_index_[order->id] = order;
108109

109110
// FOK: only execute if the whole quantity can be filled right now.
110111
if (order->type == OrderType::FOK && !can_fill_completely(order)) {
111-
order->cancel();
112+
order->cancel(ts_);
112113
order_index_.erase(order->id);
113114
notify_order(*order);
114115
return order;
@@ -121,7 +122,7 @@ class ArrayOrderBook {
121122
rest_order(order);
122123
} else {
123124
// Market / IOC / FOK remainder, or out-of-band limit, cancels.
124-
order->cancel();
125+
order->cancel(ts_);
125126
order_index_.erase(order->id);
126127
notify_order(*order);
127128
}
@@ -280,7 +281,7 @@ class ArrayOrderBook {
280281
trade.sequence = next_sequence_++;
281282
trade.price = resting->price; // price improvement to the resting side
282283
trade.quantity = fill_qty;
283-
trade.exec_time = now();
284+
trade.exec_time = ts_;
284285
trade.aggressor = incoming->side;
285286
std::memcpy(trade.symbol, incoming->symbol, sizeof(trade.symbol));
286287

@@ -293,8 +294,8 @@ class ArrayOrderBook {
293294
}
294295

295296
level.reduce_quantity(fill_qty);
296-
incoming->fill(fill_qty);
297-
resting->fill(fill_qty);
297+
incoming->fill(fill_qty, ts_);
298+
resting->fill(fill_qty, ts_);
298299

299300
notify_trade(trade);
300301
notify_order(*resting);
@@ -378,6 +379,8 @@ class ArrayOrderBook {
378379
std::unordered_map<OrderId, Order*> order_index_;
379380
ArenaAllocator<Order> order_arena_;
380381

382+
Timestamp ts_{}; // wall-clock captured once per inbound event (hot path)
383+
381384
SeqNum next_sequence_ = 1;
382385
uint64_t trade_count_ = 0;
383386
uint64_t total_volume_ = 0;

core/include/Order.h

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -117,18 +117,23 @@ struct alignas(64) Order {
117117
return status == OrderStatus::New || status == OrderStatus::PartiallyFilled;
118118
}
119119

120-
void fill(Quantity qty) noexcept {
120+
// Hot-path variants take a caller-supplied timestamp. The matching engine
121+
// captures now() once per inbound event and threads it through every fill,
122+
// rather than re-reading the clock (~17ns) several times per order.
123+
void fill(Quantity qty, Timestamp ts) noexcept {
121124
filled_qty += qty;
122125
leaves_qty -= qty;
123-
last_update = now();
126+
last_update = ts;
124127
status = (leaves_qty == 0) ? OrderStatus::Filled : OrderStatus::PartiallyFilled;
125128
}
129+
void fill(Quantity qty) noexcept { fill(qty, now()); }
126130

127-
void cancel() noexcept {
131+
void cancel(Timestamp ts) noexcept {
128132
status = OrderStatus::Cancelled;
129133
leaves_qty = 0;
130-
last_update = now();
134+
last_update = ts;
131135
}
136+
void cancel() noexcept { cancel(now()); }
132137
};
133138

134139
// ─────────────────────────────────────────────

core/include/OrderBook.h

Lines changed: 15 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,7 @@ class OrderBook {
9292
*/
9393
Order* add_order(const NewOrderRequest& req) {
9494
// Allocate from arena (zero malloc)
95+
ts_ = now(); // one clock read per event; reused for every fill
9596
Order* order = order_arena_.allocate();
9697
new (order) Order{};
9798

@@ -105,8 +106,8 @@ class OrderBook {
105106
order->quantity = req.quantity;
106107
order->leaves_qty = req.quantity;
107108
order->filled_qty = 0;
108-
order->entry_time = now();
109-
order->last_update = order->entry_time;
109+
order->entry_time = ts_;
110+
order->last_update = ts_;
110111
order->status = OrderStatus::New;
111112
std::memcpy(order->symbol, req.symbol, sizeof(order->symbol));
112113

@@ -136,14 +137,14 @@ class OrderBook {
136137
case OrderType::Market:
137138
case OrderType::IOC:
138139
// Cancel unfilled remainder
139-
order->cancel();
140+
order->cancel(ts_);
140141
order_index_.erase(order->id);
141142
notify_order(*order);
142143
break;
143144
case OrderType::FOK:
144145
// Should have been fully filled or not at all
145146
// (FOK pre-check happens in match())
146-
order->cancel();
147+
order->cancel(ts_);
147148
order_index_.erase(order->id);
148149
notify_order(*order);
149150
break;
@@ -201,6 +202,7 @@ class OrderBook {
201202
Order* order = it->second;
202203
if (!order->is_active()) return false;
203204

205+
ts_ = now();
204206
bool price_changed = (req.new_price != 0 && req.new_price != order->price);
205207
bool qty_increased = (req.new_quantity != 0 && req.new_quantity > order->leaves_qty);
206208

@@ -215,7 +217,7 @@ class OrderBook {
215217
}
216218
order->sequence = next_sequence_++;
217219
order->status = OrderStatus::Amended;
218-
order->last_update = now();
220+
order->last_update = ts_;
219221

220222
// Re-match then rest
221223
match(order);
@@ -228,7 +230,7 @@ class OrderBook {
228230
order->leaves_qty = req.new_quantity;
229231
order->quantity -= reduction;
230232
order->status = OrderStatus::Amended;
231-
order->last_update = now();
233+
order->last_update = ts_;
232234

233235
// Update level aggregate
234236
auto& levels = order->is_buy() ? bids_ : asks_;
@@ -405,7 +407,7 @@ class OrderBook {
405407
trade.sequence = next_sequence_++;
406408
trade.price = resting->price; // Resting order's price (price improvement)
407409
trade.quantity = fill_qty;
408-
trade.exec_time = now();
410+
trade.exec_time = ts_;
409411
trade.aggressor = incoming->side;
410412
std::memcpy(trade.symbol, incoming->symbol, sizeof(trade.symbol));
411413

@@ -422,8 +424,8 @@ class OrderBook {
422424
// because fill() modifies the order in-place and the level still references it.
423425
// Learned this the hard way (assertion failures in PriceLevel::reduce_quantity).
424426
level.reduce_quantity(fill_qty);
425-
incoming->fill(fill_qty);
426-
resting->fill(fill_qty);
427+
incoming->fill(fill_qty, ts_);
428+
resting->fill(fill_qty, ts_);
427429

428430
// Notify
429431
notify_trade(trade);
@@ -536,7 +538,7 @@ class OrderBook {
536538
o->tif = TimeInForce::GTC;
537539
}
538540
o->sequence = next_sequence_++;
539-
o->last_update = now();
541+
o->last_update = ts_;
540542
++stop_triggered_count_;
541543

542544
match(o);
@@ -545,7 +547,7 @@ class OrderBook {
545547
if (o->type == OrderType::Limit) {
546548
rest_order(o);
547549
} else {
548-
o->cancel();
550+
o->cancel(ts_);
549551
order_index_.erase(o->id);
550552
notify_order(*o);
551553
}
@@ -605,6 +607,8 @@ class OrderBook {
605607
std::unordered_map<OrderId, Order*> order_index_;
606608
ArenaAllocator<Order> order_arena_;
607609

610+
Timestamp ts_{}; // wall-clock captured once per inbound event (hot path)
611+
608612
SeqNum next_sequence_ = 1;
609613
uint64_t trade_count_ = 0;
610614
uint64_t total_volume_ = 0;

0 commit comments

Comments
 (0)