A high-speed financial exchange engine. OCaml-Limit pairs buyers and sellers at sub-millisecond speeds, featuring a live Bloomberg-style trading terminal.
At modern stock exchanges, millions of buy and sell orders arrive every second. OCaml-Limit is the core engine responsible for matching buyers and sellers instantly and fairly. It is built to run continuously with zero micro-pauses or lag, paired with an interactive market dashboard.
The matching hot path is allocation-free per submit (bench-validated), achieves ~18 million orders/sec in clean performance benchmarks, and holds p99 latency under 1 microsecond.
π’ Live demo: ocaml-lob.vercel.app (dashboard hosted on Vercel, matching engine running on an Oracle Cloud VM). Click the info icon in the top right for an in-app tour.
A working implementation of an exchange-grade matching engine. The engine matches trades based on price and time priority, supporting standard professional order types:
- Limit and Market Orders: Standard trades at specified or market prices.
- Iceberg Orders: Large orders that hide total volume to avoid moving the market.
- Immediate-or-Cancel (IOC) and Fill-or-Kill (FOK): Time-in-force conditions that prevent partial or lingering fills.
- Pre-trade Risk Checks: Automated guardrails blocking invalid or oversized orders.
The core API is compact:
open Ocaml_lob.Types
module Engine = Ocaml_lob.Engine
let engine = Engine.create default_config in
let order = make_order ~id:1 ~side:Buy ~price:1_502_500 ~qty:100
~order_type:Limit ~timestamp:0 in
match Engine.submit engine order (fun passive_id active_id price qty _ts ->
Printf.printf "fill: %dΓ%d @ %d (passive=%d active=%d)\n"
qty 1 price passive_id active_id)
with
| Ok () -> () (* order accepted *)
| Error _ -> () (* pre-trade risk rejected *)Six layered optimizations eliminate memory allocation overhead on the matching hot path:
- Reusing pre-allocated result constants
- Direct exception handling to avoid memory wrapper allocations
- Top-level execution loops that avoid runtime closures
- Compact doubly linked lists for price levels
- Reusing inactive price levels rather than rebuilding tree structures
- Fast custom hash tables replacing general-purpose map allocations
Full architectural details are in ONBOARDING.md.
sequenceDiagram
actor User as User<br/>(browser)
participant Caddy
participant Server as Dream HTTP<br/>(OCaml)
participant Engine as Matching<br/>Engine
participant Bot as Demo Bot
User->>Caddy: HTTPS GET /
Caddy->>Server: HTTP GET /
Server-->>User: index.html + app.js + favicon
User->>Caddy: GET /events (SSE)
Caddy->>Server: chunked HTTP stream
rect rgba(0, 240, 255, 0.08)
note over Server,Engine: every 500 ms while SSE open
Server->>Engine: snapshot_to_json
Engine-->>Server: book state via DLL walk
Server-->>User: data: SNAPSHOT (depth + spread)
end
rect rgba(0, 255, 0, 0.08)
note over Bot,Engine: bot loop, ~1 order/sec
Bot->>Engine: submit synthetic order
Engine-->>Bot: on_fill callback per fill
Bot->>Server: push trade to ring
Server-->>User: data: TRADE (tape entry, drained on tick)
end
User->>Server: POST /order {side, price, size}
Server->>Engine: Engine.submit
Engine-->>Server: on_fill β push to ring
Server-->>User: 200 {"status":"ok"} (TRADE follows via SSE)
Caddy on the host handles TLS. The OCaml binary inside the container handles everything else. SSE replaced an earlier WebSocket transport after gluten_lwt's WS close path was found to wedge the event loop in a tight retry-on-failed-write cycle (see commit 0c62bc0).
Demo videos below were recorded with Playwright + the project's local E2E suite. Each one shows a single user flow end-to-end so you can see the dashboard behaving live.
Live order book + depth chart + trade tape
The dashboard streams L2 book snapshots every 500 ms over Server-Sent Events. The depth chart on the center pane visualizes resting liquidity; the trade tape on the right shows every fill the engine produces (both from the in-process demo bot and from manual orders).
Manual order entry with live risk feedback
The Quick Entry panel POSTs orders straight into the running engine. Rejected orders surface in the Risk Engine Activity log on the left.
First-visit onboarding modal
Auto-opens on first visit with the headline numbers and a panel-by-panel guide. Dismissal persists in localStorage; the (i) icon in the header re-opens it.
For maintainers: recording flow β
npm --prefix e2e run demorecords mp4s at 1440Γ900 intoe2e/demo-output/, thennpm --prefix e2e run gifsconverts them to 1280px-wide GIFs inassets/demos/. Every feature has a-lightand-darkvariant; the README embeds them via<picture media="(prefers-color-scheme: dark)">so the active gif follows the reader's browser theme. Recorded close to the README's display resolution so the GIFs are crisp on Retina without blowing GitHub's 10 MB inline-image cap.
Prerequisites: opam 2.x with an OCaml 5.x switch active.
opam install --deps-only --with-test -y . # alcotest + transitive
opam install -y dream yojson lwt_ppx # server-only deps
dune build # compile everything
dune runtest # 11 tests across matching + perf
dune exec bin/server.exe # serves on localhost:8080
dune exec bin/bench.exe # 1M-order benchmark with allocation report
dune exec bin/diag.exe # bytes/order at varying scalesOpen http://localhost:8080/. The demo bot starts automatically.
For deploying to your own VM (Oracle Cloud Always-Free walkthrough), see DEPLOY.md.
.github/workflows/deploy.yml wires up CI + CD on every push to main.
flowchart LR
push["git push main"] --> test["test job<br/>dune build + runtest"]
test -->|β pass| build["build-and-push<br/>linux/amd64 image<br/>+ push to GHCR"]
test -->|β fail| abort["abort β broken code<br/>never reaches the VM"]
build --> deploy["deploy<br/>SSH β docker pull<br/>β docker run β smoke test"]
deploy --> live["ocaml-lob.duckdns.org"]
Sequential gating: the build job has needs: test, so a single Alcotest failure prevents the image from being built β let alone pushed or deployed. The deploy job has needs: build-and-push, so an image that fails to build never reaches the VM. Concurrency-gated (group: deploy-prod) so two simultaneous pushes can't race on the host.
Caching: ocaml/setup-ocaml@v3caches the opam switch (~2 min cache-warm vs ~8 min cold). Docker layers cache viacache-from: type=gha, so source-only changes rebuild the image in ~1.5 min.
Secrets (SSH_HOST, SSH_USER, SSH_PRIVATE_KEY) are resolved at workflow start time and used by appleboy/ssh-action to reach the VM. Setup walkthrough in DEPLOY.md.
Two Alcotest files under test/, 11 cases total. Runs in well under a second.
| Group | Test | What it pins |
|---|---|---|
matching |
Basic matching | Same-price Buy/Ask filling each other |
matching |
Iceberg reload | Visible-portion exhausts, hidden portion reloads at the queue tail; total qty conserved |
matching |
Post-only rejection | Crossing post-only returnsReject_price_band |
matching |
Cancel preserves FIFO | Canceling a mid-queue order leaves FIFO linkage intact across the gap |
matching |
Sweep across levels | Aggressive order eating partial fills across 3 price levels with correctpl_total_qty |
matching |
Level resurrection | Empty level + new order at same price re-promotesbest_level |
messaging |
MPSC FIFO (1 producer) | Single-producer/consumer round-trip preserves order |
messaging |
MPSC FIFO (4 domains) | 4 OCaml Domains push concurrently; per-producer FIFO holds across 1000 messages |
regression |
Zero per-order allocation | β€ 0.20 bytes/order over 100k submits (measured: ~0.06) |
regression |
Throughput floor | β₯ 0.5 M orders/sec (measured: ~30+) |
regression |
p99 latency ceiling | β€ 100 ΞΌs (measured: ~1 ΞΌs) |
The two matching cases that originally surfaced as test failures (Sweep across levelsandLevel resurrection) caught a real production bug β an OCaml scope gotcha where else let β¦ in β¦; stmt; stmt;silently extended into following statements, droppingpl_total_qtyand the resurrection check on thetail_idx = -1 code path. Fix shipped; the tests stay as regression sentinels.
ocaml_lob/
βββ lib/ engine + types + custom data structures
β βββ types.ml price_level, book_side, sentinel_level
β βββ engine.ml submit, fill_loop, link_level
β βββ price_index.ml open-addressing hashtable replacing stdlib Map
β βββ pool.ml pre-allocated order pool
β βββ risk.ml pre-trade gates (variant returns, no exns)
β βββ stats.ml nanosecond latency tracker
β βββ messaging.ml OCaml 5 atomic MPSC queue
βββ bin/
β βββ server.ml Dream HTTP: GET /events (SSE), POST /order, demo bot
β βββ bench.ml 1M-order benchmark, prints throughput + alloc
β βββ diag.ml raw bytes/order measurement via Gc.minor_words
βββ test/
β βββ test_engine.ml matching + messaging cases
β βββ perf_test.ml regression guards
βββ front/ dashboard (vanilla JS + Tailwind CDN + Chart.js)
βββ assets/ repo-only banner artwork
βββ Dockerfile multi-stage: ocaml/opam β ubuntu:22.04
βββ DEPLOY.md Oracle Cloud E2.1.Micro walkthrough
βββ ONBOARDING.md architecture deep-dive for new contributors
βββ .github/workflows/deploy.yml CI (test) + CD (build + ship)
- ONBOARDING.md β full architecture, the six perf fixes explained, known gotchas, hang runbook
MIT. See ocaml_lob.opam for full package metadata.