Skip to content

Repository files navigation

limit-order-book

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 gc allocation profile

Design

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.

Modify semantics

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

Quickstart

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    # benchmarks

CI 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.

API

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,...}

Results

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 verify

A 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.

Skills this project exercises

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.

Status

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.

License

MIT — see LICENSE.

About

Limit order book and matching engine in Java: price-time priority, add/cancel/modify, partial fills, with measured throughput and p99.9 tail latency

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages