A limit order book and matching engine written from scratch in Java, with a Spring Boot service around it and benchmarks that produce real, reproducible throughput and tail-latency numbers.
- Price-time priority — best price first, FIFO within a price level
- Add / cancel / modify — O(1) cancel via intrusive linking
- Partial fills, limit and market orders, DAY / IOC / FOK
- Multi-symbol, L2 depth snapshots, execution-report stream
- Measured: JMH throughput, HdrHistogram p99.9 tail latency,
-prof gcallocation profile
The matching engine is plain Java. engine-core has no Spring dependency and
does not allocate on its submit / cancel / modify paths — that is what
makes the latency numbers below worth reading. Spring Boot sits around it as
an API and ops layer, fed by a single-consumer command queue so the engine
stays single-threaded.
engine-core/ plain Java: Order, PriceLevel, OrderBook, MatchingEngine
engine-bench/ JMH + HdrHistogram harness
engine-api/ Spring Boot: REST, WebSocket, actuator, book viewer
Prices are long tick counts and quantities are long throughout — never
floating point.
| Change | Time priority |
|---|---|
| Price changed | Lost — re-queued at the back of the new level |
| Quantity increased | Lost — re-queued at the back of the level |
| Quantity decreased only | Kept — stays in place in the queue |
Requires a JDK (21+). No Maven install needed — the wrapper bootstraps it.
git clone https://github.com/thompgt/limit-order-book.git
cd limit-order-book
./mvnw -B verify # checkstyle + build + tests
./mvnw -B install -DskipTests # put engine-core in the local repo
./mvnw -pl engine-api spring-boot:run # API + UI on http://localhost:8080
./mvnw -pl engine-bench -am -Pbench verify # benchmarksCI runs verify on JDK 21 and 24 — the level the project targets and the
one it actually runs on. Checkstyle is bound to the validate phase, so a style
violation fails locally rather than being discovered after a push.
The install is needed once before -pl engine-api will resolve engine-core.
On Windows PowerShell use .\mvnw.cmd in place of ./mvnw.
Open http://localhost:8080 for the book viewer: a live depth ladder, an order ticket, and the execution tape. It is one static HTML file with no build step — adding an npm toolchain to a Maven-only repository would cost a second lockfile and a second thing to break in CI, and this page renders two lists.
POST /api/v1/orders |
submit. {symbol, side, type, timeInForce, price, quantity, orderId?, accountId?} — price is required for a LIMIT order and must be a positive tick |
DELETE /api/v1/orders/{id} |
cancel |
PATCH /api/v1/orders/{id} |
modify. {price, quantity} — quantity is the new total |
GET /api/v1/book/{symbol} |
L2 depth, ?levels=N |
GET /api/v1/symbols |
what is trading |
ws://…/stream/{symbol} |
execution reports as they happen, plus a depth snapshot every 250ms |
GET /actuator/prometheus |
lob_engine_queue_depth, lob_pool_allocations, lob_stream_dropped, lob_stream_failed, … |
This service is not hardened. There is no authentication, no authorization
and no rate limiting on order entry — it is a demo of a matching engine, not a
venue. Health details are when-authorized rather than always for the same
reason. Do not expose it beyond localhost without putting something in front of
it.
A reject carries the status that describes it — duplicate id 409, unknown
symbol or order 404, anything else 400 — so a client never has to read a
body to find out whether its order worked. A full command queue is 503 with
Retry-After: that is a load condition, not a defect in the request, and the
command is abandoned rather than left queued — so retrying on it cannot land
the same order twice.
An order id is unique among live orders only — it is free again once the
order fills or cancels. Every event on the stream therefore also carries the
engine sequence (and restingSequence for the passive side of a trade),
which is monotonic and never reused: key a tape by that, not by orderId.
A client-supplied orderId must be below 1,000,000,000 — ids at or above that
are the service's own, and letting a client claim one means its sequence walks
into it later and 409s an order that was never a duplicate.
An order may carry an accountId. It is optional and off by default, but it is
what self-trade prevention needs to exist at all — without a participant
identity, one client's aggressive order happily lifts its own resting quote.
Set lob.self-trade-policy to CANCEL_RESTING, CANCEL_AGGRESSOR or
CANCEL_BOTH to turn it on; OFF keeps the matching loop exactly as the
benchmarks measure it.
Prices and quantities are bounded (lob.max-price, lob.max-quantity, both
10^12 by default). The bound is not policy: a price level's aggregate quantity
is a long, so unbounded orders could wrap it negative and make depth and
fill-or-kill answer from a negative number instead of failing.
curl -X POST localhost:8080/api/v1/orders -H 'content-type: application/json' \
-d '{"symbol":"AAPL","side":"SELL","price":100050,"quantity":25,"orderId":11}'
# {"orderId":11,"status":"RESTING","filledQuantity":0,"restingQuantity":25,...}Measured on AMD Ryzen 5 5500U (6 cores / 12 threads, 32 GB), Windows 11, Temurin JDK 24, with everything below produced by one command:
./mvnw -pl engine-bench -am -Pbench verifyA laptop under a desktop OS is not a trading server, and these numbers should be read as a floor rather than a result. They are here because a number someone can re-run beats a dash.
| Metric | Value | Where it comes from |
|---|---|---|
| Throughput, MIXED @ 8 levels | 3.52 M commands/sec ± 0.25 M | JMH, 5×1s, 1 fork |
| Throughput, MIXED @ 256 levels | 1.88 M commands/sec ± 0.43 M | ″ |
| Throughput, MODIFYING @ 8 levels | 15.0 M commands/sec ± 2.0 M | ″ — no ladder lookup |
| Latency p50 (service) | 0.30 µs | LatencyHarness, 500k/sec offered, 20s |
| Latency p99 (service) | 1.40 µs | ″ |
| Latency p99.9 (service) | 17.7 µs | ″ |
| Latency p99.99 (service) | 129 µs | ″ |
| Latency p99.9 (response) | 4.36 ms | ″ — see the caveat below |
| Allocation, MODIFYING | 0.67 B/op | -prof gc |
| Allocation, MIXED @ 8 levels | 16.2 B/op | ″ |
| Allocation, RESTING @ 256 levels | 30.8 B/op | ″ |
Two things in that table deserve honesty rather than a footnote.
The response-time tail is the machine, not the engine. The harness paced 500,000 commands/sec and achieved 499,996 — the engine kept up. But response time measures from when a command was due, and the pacing loop spins on a laptop running a desktop OS, so every time the scheduler took the thread away the whole backlog is charged to the engine. Service-time p99.9 is 17.7 µs while response p99.9 is 4.36 ms; a 250× gap between the two is scheduling jitter. The harness is right to report it — that is the point of measuring response time — and a quiet machine is what would separate the two.
Allocation is zero only where no price level opens or closes. MODIFYING
never touches the ladder and comes in at 0.67 B/op. The mixes that open and
close levels pay for a red-black tree node each time, which is the cost the
OrderBook javadoc describes and declines to fix. AllocationTest pins the
first case — 800,000 submit / modify / cancel commands over a book whose levels
never open or close — at literally zero bytes, on every build rather than on
the day someone remembers to run -prof gc.
Latency is measured with a fixed-rate submitter and HdrHistogram's
recordValueWithExpectedInterval, so the tail is corrected for coordinated
omission rather than flattered by it.
Each row points at the code that backs it, so the claim can be checked rather than taken on trust.
| Skill | Where it shows up |
|---|---|
| Trading systems | Price-time priority matching, aggressive-order sweeps across levels, partial fills, DAY / IOC / FOK, market orders, and the modify priority rules above — MatchingEngine, OrderBook, PriceLevel |
| Trade booking | The execution-report lifecycle: accept → trade → fill / rest / cancel / replace, each event carrying trade id, sequence, price and quantity, emitted in the order it happened — ExecutionSink, SubmitResult, CancelResult |
| Market data | L2 depth snapshots aggregated per price level, maintained incrementally so a snapshot is O(1) per level rather than a queue walk — OrderBook.snapshot, DepthVisitor. Streamed over WebSocket per symbol, with depth sampled on a clock so an unbounded book-change rate becomes a bounded message rate — MarketDataBroadcaster, DepthTicker |
| Java | Java 21, no framework and no Lombok in the core: intrusive doubly-linked lists, an ownership contract on recycled objects, sealed-off package-private mutation, and a test suite that names the semantics it pins — 267 tests, 23 of them property-based with jqwik |
| Low-latency JVM engineering | The reason for most of the above: object pooling (OrderPool), primitive-keyed maps to avoid boxing (OrderIndex), reused result objects, callbacks instead of returned collections, and JMH + HdrHistogram with coordinated-omission correction. -prof gc found the ladder allocating 24–36 B/op and drove the swap to primitive-keyed trees — see the OrderBook javadoc for what it fixed and what it did not |
| Spring | Spring Boot 3.5 as an API and ops layer — REST, WebSocket, actuator and Micrometer gauges, all fed through a single-consumer command queue that keeps the engine single-threaded under concurrent HTTP. Gauges, never timers on the command path: a timer there would measure only the commands that got to run |
The split is deliberate and is the main design idea here: Spring never touches
engine-core, because a framework sitting in the measured path would make the
latency numbers meaningless. An ArchUnit test enforces it, and lives in
engine-api because that is the only module with Spring on its classpath —
inside engine-core it would pass trivially and prove nothing.
Phases 0–5 complete: scaffold, core data structures, matching,
cancel / modify / time-in-force, benchmarks, and the Spring API with its
WebSocket feed and book viewer. 267 tests green — 221 in engine-core, 46 in
engine-api. Phase 6 (Docker image, tuning notes) is next.
Progress tracked in docs/WORKPLAN.md; working conventions
in CLAUDE.md.
MIT — see LICENSE.