Anvil

// Design & architecture

Anvil

A single-threaded, deterministic C++20 price-time-priority matching engine — the data model, the matching semantics, the correctness evidence, and the live-demo layer that wraps it unmodified — with the reasoning behind each decision and the road to production mapped.

What this is. A narrative rendering of Anvil's two golden-source documents — README.md (the engine: data model, documented decisions, testing, performance, scalability) and docs/ARCHITECTURE.md (the demo: the egress seam, concurrency model, and wire contract, with PROTOCOL.md as the canonical wire spec). Those repo docs are authoritative; this page consolidates them into one read.

What Anvil is

Anvil reads a stream of New / Cancel / Amend messages, matches them under strict price-then-time (FIFO) priority, holds the resting order book in memory, and emits deterministic, replayable output — trades and rejections in processing order, then a dump of the resting book. Input is a fixed six-field CSV line (ticker,type,id,side,qty,price); a venue matching a single instrument, distilled to its core.

Two things share one repository, and the framing is deliberate. The engine core (in src/) is the matching engine, built properly within its scope. The demo layer wraps that engine unmodified in a Crow C++ server that streams the live book to a browser trading terminal. This is the matching core done well — not a production venue; the venue-scale gaps are a designed-for boundary, named at the close, not an oversight.

// order book — price-time priority
ASKS · sell10.12#7#31,20010.11#5#2#92,60010.10#1900BESTspread 0.02 · mid 10.0910.08#1#4#83,000BEST10.07#2#61,50010.06#3800BIDS · buy← head = front of queue (time priority)LIVE
Best price first, then FIFO within each level — the head (oldest, outlined) trades first. An aggressor sweeps from the inside out, printing at each resting level’s own price.

The engine — data model

Correctness and speed both fall out of the data model. The guiding rule throughout: indices, not pointers; integers, not strings; values, not exceptions.

The core types the rest of this section builds on, in one place. The one worth pausing on is Level: it is only a FIFO queue at a single price — head/tail indices into the order pool, and nothing else — with no aggregate quantity by design.

TypeRepresentationRole
PriceTicksint32_tA price as integer ticks (price × 10⁴, 4 dp). Exact-equality comparisons — no double ever touches order data, so map keys never drift. MAX_PRICE 100,000.0000 = 10⁹ ticks, inside int32_t.
Qtyuint32_tAn order quantity. MAX_QTY = 1,000,000,000.
IdKeyuint64_tAn order id (≤ 10 chars, 63-symbol alphabet) packed losslessly into 60 bits — the hash-map key and the dump/trade-print source. No string hashing, allocation, or comparison anywhere.
OrderIdxuint32_tIndex into order_pool, used everywhere a pointer would be: survives pool reallocation, halves linkage size, keeps the engine relocatable.
LevelIdxuint32_tIndex into level_pool. The book's flat_map stores these, never Level objects — so a level never moves and Order::level stays valid for the order's whole life.
NIL0xFFFFFFFFThe null-index sentinel; also terminates both pools' intrusive free lists.
Sideenum class : uint8_tBuy / Sell.
Orderpooled structA single order: id, price, remaining, filled (cumulative — drives total-quantity amend), the level and book indices it sits in, prev/next intrusive FIFO links (next doubles as the free-list link when the slot is dead), and side.
Level{ head, tail } (8 bytes)The FIFO queue of orders resting at one price — head and tail indices into order_pool, and nothing else. No aggregate total_qty by design: the matching path never needs a level total, so it stays a known extension point (L2 depth / debug invariant).
BookSide<Cmp>flat_map<PriceTicks, LevelIdx, Cmp>One side of one book: a sorted contiguous map from price to level. begin() is best-of-book — std::greater<> for bids, std::less<> for asks.
InstrumentBookstructOne ticker's book: the ticker id plus its bids and asks sides. One per ticker.
EnginestructThe whole matching state: order_pool and level_pool (each a vector + free-list head), the books vector, the global id_index (IdKey → OrderIdx), and ticker_index (ticker → books index).

Pools and indices, everywhere

Orders and price levels live in two std::vector object pools. Every cross-reference — FIFO links, an order's owning level, the id index, the price→level maps — is a 32-bit index, never a pointer. Three concrete reasons:

  • Pools reallocate as they grow. A vector that resizes invalidates every pointer into it; 32-bit indices survive the move untouched.
  • Half the linkage size. prev / next / level as uint32_t are half the size of 64-bit pointers — denser cache lines on the hot path.
  • The whole engine is relocatable. No interior pointers means the entire engine could be serialized, snapshotted, or moved as plain bytes.

Both pools carry an intrusive free list threaded through the dead objects themselves — a freed order's next link doubles as the free-list link — so there is no side allocator bookkeeping. The two pools are separate structs (OrderPool and LevelPool), and OrderIdx / LevelIdx are both uint32_t aliases — keeping each index paired with its own pool is a discipline the code holds, not one the compiler enforces. NIL = 0xFFFFFFFF is the null index.

// data model — indices, not pointers
ticker_indexticker → book idxid_indexIdKey(uint64) → OrderIdxInstrumentBook · per tickerbids · flat_mapstd::greater — begin() = best bidasks · flat_mapstd::less — begin() = best asklevel poolLevel { head, tail } · stableorder pool · vector + intrusive free listA001A002A003prev ⇄ nextO(1) cancel / amendhead / tail
Two indices locate the book; each side is a flat_map of price → LevelIdx; levels and orders live in pools addressed by 32-bit indices. id_index gives the O(1) cancel/amend hop straight to an order.

The book — flat_map + level indirection

Each side of a book is a sorted map from price to level: bids use std::greater<> so begin() is the best (highest) bid; asks use std::less<> so begin() is the best (lowest) ask. With Boost this is a flat_map — a sorted contiguous vector of (price, levelIdx) pairs: cache-friendly, branch-predictable scans, no per-node allocation.

The map stores only the pair, never the level. That indirection is the whole trick: inserting or erasing a price level memmoves the flat_map's backing storage, so any iterator into it is instantly stale — but the Level objects sit in their own pool and never move. So an order's level index stays valid for its whole life (an O(1) hop from any order to its level, used by cancel and amend), while the matching loop never caches a flat_map iterator across an insert or erase — it copies the price/level into locals and drops the iterator before any structural change.

// flat_map — cache-resident, iterator-fragile
bids: flat_map — sorted contiguous · best = begin()memmove →10.10L310.11L710.12L110.14L4begin()L1L3L4L7Level pool · addresses never move
Insert/erase memmoves the sorted array, so any iterator into it goes stale — but a LevelIdxinto the pool stays valid for the order’s whole life. The engine caches indices, never iterators.

ID packing — no strings

Order ids are ≤ 10 chars from a 63-symbol alphabet ([A-Za-z0-9-]). Each char encodes to 1..63 in 6 bits; 10 × 6 = 60 bits fit a uint64_t. The packed key is the hash-map key and the print source. Charset and length validation fall straight out of the encoder — any unencodable char or over-length id makes the pack fail, so there is no separate id validator, no string hashing, no allocation, no comparison on the path.

Fixed-point prices — doubles never touch the data

Prices are int32_t ticks (price × 10⁴, 4 dp). The input path parses a price directly to ticks — integer part × 10⁴ plus the right-padded fraction — using only the integer overloads of std::from_chars. No double, no rounding error, exact equality comparisons, so map keys never drift. The 100,000.0000 ceiling is 10⁹ ticks, comfortably inside int32_t.

Parsing and the output seam

The whole file is slurped into one buffer and parsed in place with string_view slices — zero per-line allocations; std::from_chars is the only numeric conversion (non-allocating, locale-independent, and it reports trailing garbage, which is exactly the validation wanted). The matching core then emits semantic events — a fill happened, an order is resting, a message was rejected — not formatted text. A single Sink turns those events into output bytes; match and dump_book stay free of any string formatting. That one seam is what the demo later re-points without touching the engine.

Complexity

OperationCost
New / re-entered amend, price level existsO(log L) map lookup, then O(1) FIFO append
New / re-entered amend, brand-new levelO(log L) + O(L) memmove to insert into the flat_map
Each fill during matchingO(1) — pop FIFO head, splice links
CancelO(1) id lookup + O(1) unlink (O(L) only if the level empties)
Reduce-in-place amendO(1)
End-of-run dumpO(books · log books) sort + linear over printed orders

L is the number of distinct price levels on one side of one ticker — in practice tens, occasionally low hundreds. At that scale the asymptotics are not the story: constant factors and access patterns dominate. A contiguous flat_map of tens of entries fits in a couple of cache lines and beats a pointer-chasing node tree, even though both are “O(log L)” — flat, cache-resident, and branch-friendly wins in this size regime.

Matching semantics & rationale

The trading semantics are deliberate, each chosen with its alternative consciously rejected. These are the decisions that make Anvil a matching engine and not just a sorted container.

  • Trade price is the resting order’s price, not the aggressor’s. The passive side set the price, so a sweep through several levels prints at successively worse resting prices rather than one blended fill — the standard continuous-auction convention. The aggressor may receive price improvement.
  • Amend quantity is the new total (remaining = new_total − filled), matching cancel/replace on real venues. Price unchanged and smaller → reduce in place with time priority preserved; price changed or quantity increased → unlink and re-enter as new (loses priority, may trade immediately); new_total ≤ filled → auto-cancel (a venue convention, not an error, because the only correct resting quantity is then zero).
  • Cancel is by id alone, unconditional. The echoed side / price / quantity on a cancel are non-authoritative and ignored. Rationale: a cancel rejected on a stale echo would leave live risk resting on a book the client believes is dead — real venues cancel by id.
  • Global id uniqueness. Ids are unique across all tickers, so one global id index locates any order. A freed id (filled, cancelled, auto-cancelled) leaves the index and may be reused — consistent with the input-uniqueness assumption.
  • Errors are reported by value, not exceptions. Rejection is routine, high-frequency control flow here, so the parser returns a result and the engine funnels failures through the sink — never a throw. A rejected line stays about as cheap as an accepted one, the hot path stays allocation-free, and a thrown exception (heap-allocated, unwind tables, RTTI) would cost ~100× the engine's tens-of-ns budget. Genuinely exceptional faults like allocation failure are still left to propagate.

The amend decision flow

Every amend traces this exact dispatch — the tests follow it step for step. Side is validated only here (cancel ignores it, new establishes it), and an at-or-below-filled amend auto-cancels rather than erroring.

// amend — the decision dispatch
Amendorder id known?side matches resting?new_total ≤ filled?price unchanged?error · unknown iderror · side changeauto-cancelvenue convention, not an errorre-enter as newloses priority · may cross nowreduce / no-op in placetime priority preservedyesnonoyes · smaller / equalnonoyeschanged / larger
Total-quantity semantics (remaining = new_total − filled). A reduce or no-op keeps time priority in place; a price change or size increase re-enters at the back of the queue; at-or-below filled auto-cancels.

Correctness — evidenced five ways

Correctness is evidenced in five complementary layers — example-based, exhaustive, structural, differential, and adversarial — so a bug has to survive all five to ship. The honest limit is named too: differential testing verifies the implementation against a second one, not the interpretation — but the verifier and the reference together make “the engine does what was specified” very well evidenced.

LayerWhat it proves
Functional diffEnd-to-end output matches checked-in expecteds, byte-for-byte, across a suite of hand-built cases.
Unit (doctest)Leaf functions in isolation: id pack/decode, parser/validation, price formatting, the pool, the egress seam, server config.
Invariant verifierA --verify mode audits the full book state after every message: no crossed book, per-level FIFO integrity, id-index ↔ resting-order bijection, free lists disjoint from the live set. Zero hot-path cost when off.
Differential oracleA deliberately naive second engine (Python) is byte-diffed against the C++ engine over seeded workloads — any divergence is a bug in one of two independent implementations.
Load / adversarialThroughput and footprint under eight stress shapes at scale: level-churn, deep-book sweeps, cancel-storms, many-tickers, hot-level, trend, and more.

Two libFuzzer harnesses round it out: one asserts the parser never crashes on any byte string; the other drives the whole engine and runs the invariant verifier after every message — fuzzing memory safety and structural invariants together. Any reproducer is minimised and pinned as a regression line.

Performance

Benchmark mode runs the full pipeline — slurp, parse, validate, match, format trades and errors — and times only the processing loop (output is formatted into the buffer exactly as in normal mode, then discarded; only the write to stdout is skipped). Numbers are the median of ≥ 5 reps after a warm-up. On a 1,000,000-message mixed workload (16 tickers, ~875k trades, ~70k rejections):

Box / toolchainBackendThroughput
Ubuntu 24.04, Ryzen 7 3800X, GCC 13Boost / std (level)~2.65 M msgs/s
Windows 11, MinGW-w64 GCC 15.2Boost (flat_map)~2.03 M msgs/s
Windows 11, MinGW-w64 GCC 15.2std fallback (std::map)~1.55 M msgs/s

That is ≈ 0.38–0.49 µs per message end-to-end including parse and trade formatting; the core book operation itself is tens of nanoseconds. The Boost-vs-std gap is the flat-vs-node-map effect, but its size and even its sign move with the standard library and the hardware — Boost leads ~1.3× on Windows MinGW, the two are level on Ubuntu GCC, and std edges ahead on the Ubuntu uniform shape — which is why no fixed multiplier is quoted. The adversarial sweep also confirms the footprint claim with data: because a cancel-storm caps the live population, the engine's own memory plateaus as message count grows — memory tracks live order count, not total messages, because the pools recycle freed slots.

The live demo — architecture

The demo wraps the engine without changing it. One anvil_server (Crow) owns the matching engine on a dedicated thread and serves the REST API, the WebSocket stream, and an on-demand dummy-order feeder from one origin. The point is watching the book move, so the streaming path is the heart of it; REST is the control surface.

The engine is never modified, only re-pointed

The only engine-side change is what the output sink reference points at. In the CLI it is a CliSink producing byte-identical output to the take-home submission; in the server it is a TeeSink that fans the same events out to several subscribers. Matching, the book, and order semantics are unchanged — and that is proven against the existing functional diff suite, which still passes byte-for-byte. One repo, a logical (not physical) split: engine in src/, server in server/, web client in web/, and the client/server boundary is the versioned PROTOCOL.md contract, not a repo split.

The egress seam is the Composite pattern

This is the load-bearing design idea. EventSink is the component interface; the concrete sinks are leaves; TeeSink both is an EventSink and holds a collection of them, so the engine forwards to one reference and the tee multiplexes — unaware whether it is one sink or many.

// egress seam — Composite pattern
Engineone EventSink&TeeSinkfan-out · noexceptCliSinkbyte-identical CLI textWsPublishSink→ WS ring · non-blockingVerdictSinkaccept / reject captureJournalSinkdurable log · futureVenueSinkreliable session · futurefuture ↓
The engine emits to one EventSink&; the TeeSink fans the same events out to the leaves. New consumers (dashed) attach as tee children — the engine and existing sinks never change.
SinkRole
CliSinkByte-identical CLI text output — the engine-untouched proof.
WsPublishSinkNon-blocking push onto a bounded SPSC ring the broadcaster drains — trades only.
VerdictSinkCaptures the per-message accept/reject so POST /api/order can return a synchronous verdict without adding a return path to src/.
JournalSink (future)Durable log / system of record / replay — attaches as another tee child, touching neither the engine nor the existing sinks.
VenueSink (future)Reliable session: sequencing, acks, resend, idempotency — also just another tee child.

Two properties are load-bearing: the engine emits one FillEvent per fill carrying both taker and maker ids (the CLI's two-line text becomes a rendering detail, removing the half-written-trade window); and sink methods are noexcept, achieved by an allocation-free emit path, which is what makes the tee's fan-out atomic — neither one sink nor the loop across sinks can half-apply.

Concurrency — one writer, queues everywhere else

Exactly one thread ever calls into the engine; everything else communicates through queues. This is the one rule that keeps the demo's determinism identical to the take-home's.

// request lifecycle — one writer, queues elsewhere
BrowserterminalRESThandlerinbound qMPSCenginesingle writerbroadcasteroff-threadPOST /api/orderenqueue + promisedequeue (serial)parse + match → Teeverdict (promise)200 {accepted, reason}push trade (ring)WS: trade · snapshot (seq)
The verdict returns synchronously over REST via a promise; trades and coalesced snapshots stream back over the WebSocket, each frame carrying a monotonic seq. Only the engine thread ever touches the book.
  • Engine thread owns the engine and its tee. It drains the inbound queue, applies each message serially, emits through the tee, and on a fixed coalesce tick publishes the latest top-N snapshot via an atomic swap.
  • Inbound MPSC queue. REST handlers and the feeder are producers. A manual order carries a promise so the handler can return the engine's verdict synchronously; feeder messages carry none.
  • Outbound ring + broadcaster. The publish sink does nothing but push a structured event and return; a separate broadcaster thread serialises to JSON and fans out to WebSocket clients. All formatting and socket I/O happen off the matching thread.
  • Overflow is each consumer’s problem. The display path is best-effort: on a full ring it drops and flags stale, and the client recovers from the next snapshot. The engine's per-event cost is bounded and independent of any consumer's speed.
  • The feeder is on-demand, parked behind a two-gate predicate (a WS client is connected AND the operator toggle is on), so a burstable single instance costs almost nothing at rest.

The wire contract

REST is the control surface; WebSocket is the stream. Order entry reuses the engine’s own format — the POST /api/order body is a raw engine CSV line fed straight into the existing parser, so there is no second validation path. WebSocket frames are JSON, every frame carrying a monotonic seq: a snapshot (full top-N book, on connect and as the coalesced periodic update), and one trade per fill.

  • Errors are not on the stream. A rejected order returns HTTP 200 with {accepted: false, reason} — a business outcome in the body, not a transport error. 4xx/5xx are reserved for genuine non-verdicts (queue full, engine timeout, malformed HTTP, blocked origin). This mirrors a real venue: the session accepts the message and the rejection comes back as an execution report.
  • Coalescing bounds egress. Book state publishes at a fixed cadence (~10–15 Hz) regardless of match rate; trades stream individually. Egress is bounded independent of the match rate.
  • Reconnect is idempotent. A client opens the socket, receives a snapshot, and discards any buffered frame with seq ≤ the snapshot's seq. Ring overflow surfaces as a seq gap, and the client resyncs automatically.
  • Decimals on the wire are byte-identical to the CLI, because the JSON is hand-rolled over the engine's same inline price formatter — one source of truth for the wire format.

In production, nginx terminates TLS, serves the static React/TS client, and reverse-proxies REST and the WebSocket upgrade to the local server; systemd supervises it with an env-file. There are no external runtime dependencies. It is deployed on AWS Lightsail behind nginx.

// deployment — one process, behind nginx
Browserladder · tape · chartLinux host · AWS Lightsail · single instancenginxTLS · static · proxyanvil_server · Crowengine threadowns Engine + Teebroadcasterring → WS clientsfeeder · on-demandHTTPSRESTWSproxy
One origin: nginx terminates TLS, serves the static client, and reverse-proxies REST and the WS upgrade to the localanvil_server, which links the engine as a library. systemd supervises it; no external runtime deps.

Why these choices

Indices over pointers, as a single discipline. Choosing 32-bit indices for every cross-reference is not a micro-optimisation in isolation — it is one decision that simultaneously survives pool reallocation, halves linkage size for denser cache lines, and makes the whole engine relocatable. One constraint, three payoffs — held as a discipline: OrderIdx and LevelIdx are plain uint32 aliases, not distinct types, so pairing each index with its own pool is a convention the code keeps rather than an invariant the compiler enforces.

Integers and fixed-point, so equality is exact. Packing ids into a uint64 and parsing prices straight to integer ticks removes strings and doubles from the data entirely. The point is not just speed: exact tick equality means map keys never drift, which is what lets the engine be deterministic and replayable in the first place. Correctness and performance come from the same choice.

Single-threaded by design, not by omission. Per-message work is tens of nanoseconds; an SPSC hand-off to a matching thread would cost on the same order as the work itself. Threading a single book would forfeit the determinism a venue requires and buy nothing. The serial core is the deliberate answer, and horizontal sharding — not threads — is named as the scaling axis.

One output seam, so the demo costs the engine nothing. Because the engine always emitted to a single sink reference, wrapping it in a live server was a matter of re-pointing that reference at a tee — not editing the engine. The byte-identical CLI output still passes the original diff suite, so “the demo didn't change the engine” is proven, not asserted. Designing the seam before it was needed is what made the demo cheap.

Errors by value, because rejection is normal here. A poison feed is expected to reject a large fraction of its lines, so rejection is routine control flow, not an exceptional event. Returning a result and routing it through the sink keeps a bad line about as cheap as a good one and the hot-path latency deterministic — whereas a throw would cost ~100× the per-message budget. Exceptions are reserved for the genuinely exceptional, like allocation failure.

The boundary is stated, not blurred. Anvil is explicitly the matching core done well, not a production venue. Durability, HA, real gateways, risk, self-trade prevention, and order-type breadth are absent on purpose, and the write-up says so — placing the seams they would attach at, but not pretending they are built. Naming the boundary is itself part of the engineering judgment on show.

Future areas for development

What separates Anvil from a venue is a whole category of work, and almost none of it is built — by design. The architecture is positioned for most of it at seams that already exist; the gaps that do not attach at a seam are honest engine work, and are named as such. Placing the seams without yet building behind them is the deliberate scope: the matching core, done properly, with the road out mapped.

Scaling out — shard by ticker

Matching is inherently serial per ticker — price-time priority is a total order over arrivals, and deterministic output is a hard venue requirement — so threading a single book buys nothing and forfeits determinism. The scaling axis is therefore horizontal: a thin router fans messages out by ticker to N engine instances, each owning a disjoint ticker set and running exactly this serial loop. Almost every structure is already per-ticker; the one truly global structure is the id index, which a sharded deployment either enforces at the router (it already maps id→shard for cancels/amends) or partitions per shard. Router plus per-shard determinism preserves a globally reconstructable trade sequence.

A windowed dense array for hot instruments

Beyond flat_map, the production path for a liquid instrument is a tick-indexed dense array — a contiguous block of levels addressed by (price − anchor), giving true O(1) best-price and level access with no search and no memmove, re-anchoring (a bounded copy) only when the touch drifts past the window. flat_map is the right default for a sparse arbitrary price range at assessment scale; the dense window is the right answer when the active band is narrow and hot — exactly the shape the trend adversarial workload exercises.

Durability, reliability, and depth — at the seams

  • Durability & crash recovery. The single inbound queue is the sequencing point; command journaling there plus deterministic replay is the recovery path. The deterministic serial core is also what makes replication tractable. A JournalSink (durable log, system of record) attaches as another tee child.
  • Reliable execution reporting. A VenueSink — sequencing, acks, resend, idempotency — is where accurate per-order fill attribution for the blotter lives, and likewise attaches as a tee child without touching the engine.
  • L2 / aggregate depth. Level deliberately stores no aggregate quantity (it is 8 bytes: just head/tail). A depth feed would add one quantity per level, maintained on every fill/insert/unlink — a known, isolated extension point.
  • Real order-entry gateways. Production FIX or binary ingress and a sequenced market-data feed attach in front of the inbound queue; the CSV-over-REST contract is the demo's stand-in.

Honest engine work — the matching-semantics gaps

Some gaps do not attach at a seam and are genuine engine work: self-trade prevention (there is no owner field today), broader order types and auctions, and pre-trade risk. And the demo's v1 runs one shared, unauthenticated book by design — a “trading floor” feel — which is the obvious thing to gate first if it is ever exposed more widely. These are named as boundaries, not hidden as omissions.

← Back to the Anvil project