Skip to content

Latest commit

Β 

History

142 Commits

Folders and files

NameName
Last commit message
Last commit date
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 

Repository files navigation

OCaml LOB: Limit-Order-Book Matching Engine in OCaml 5

OCaml LOB

CI License OCaml Dream Tailwind CSS Chart.js Docker Playwright Caddy Oracle Cloud Demo

A high-speed financial exchange engine. OCaml-Limit pairs buyers and sellers at sub-millisecond speeds, featuring a live Bloomberg-style trading terminal.

πŸ’‘ What is OCaml-Limit?

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.


πŸ›  Technical Architecture

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:

  1. Reusing pre-allocated result constants
  2. Direct exception handling to avoid memory wrapper allocations
  3. Top-level execution loops that avoid runtime closures
  4. Compact doubly linked lists for price levels
  5. Reusing inactive price levels rather than rebuilding tree structures
  6. Fast custom hash tables replacing general-purpose map allocations

Full architectural details are in ONBOARDING.md.


Architecture (user flow)

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)
Loading

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


Features

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 Live order book, depth chart, and 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 Submitting a manual order from the Quick Entry panel

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 First-visit onboarding modal: open, dismiss, re-open via info icon

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/, then npm --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.


Try it locally

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 scales

Open http://localhost:8080/. The demo bot starts automatically.

For deploying to your own VM (Oracle Cloud Always-Free walkthrough), see DEPLOY.md.


CI/CD pipeline

.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"]
Loading

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.


Test suite

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.


Project layout

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)

Documentation

  • ONBOARDING.md β€” full architecture, the six perf fixes explained, known gotchas, hang runbook

License

MIT. See ocaml_lob.opam for full package metadata.

About

A high-speed financial exchange engine that pairs buyers and sellers at sub-millisecond speeds, featuring a live Bloomberg-style trading terminal.

Topics

Resources

Contributing

Stars

2 stars

Watchers

0 watching

Forks

Packages

Contributors

Languages