Limit-order-book microsimulation — a shared activity driver → stochastic order flow → a matched book

Methodology card. This is the primary human- and agent-legible description of the model. The runnable stub beside it (stub.go) is the type-checked generative demonstration; this card carries the structure, assumptions, and validity regime the Go code does not spell out. Unusually for the catalogue, the declarative twin (declarative.yaml) is the form the downstream repo actually runs — see Bespoke extensions.

System

A single trading venue’s limit order book, at the level of counts rather than individual orders: how many lots rest at each of eight price levels on each side, how that resting depth is churned by arrivals and cancellations, and how marketable orders eat into it. The quantity of interest is the book’s stability — the bid–ask spread, the resting depth, and above all the sign and strength of the coupling between arrivals and depth, which is the market’s “self-stabilising brake.” Every output is a counterfactual about market state; the model makes no directional claim about price.

The whole book co-moves because a single latent activity process scales every flow: when activity rises, arrivals, churn and cancellations all rise together. Arrivals are damped by resting depth raised to an activity-dependent power — the one calibrated mechanism — so a deep book suppresses the very arrivals that would deepen it further.

The generative core is four partitions:

Partition Iteration State Role
activity ActivityIteration [activity] Shared latent AR(1) driver; one Gamma innovation per step
flows FlowsIteration [arr_bid×8, arr_ask×8, can_bid×8, can_ask×8, buy, sell, clip] All stochastic order flow: depth-damped limit arrivals, quote churn, cancellations, one marketable order split buy/sell
book BookIteration [bid×8, ask×8, swept] Deterministic matching engine: apply flows, walk each marketable order across levels
observables ObservablesIteration [n_limit, n_cancel, n_market, depth_start, spread_ticks, clip_binds] Derived market summaries read off the book and flows

Two read mechanisms, both load-bearing. flows reads the driver at the current step (a within-step params_from_upstream edge) but the book’s resting depth one step back (a lag read of the book’s committed state). The lag is the model, not an optimisation: arrivals are damped by the depth that was resting when they were drawn, which makes flows → book the only within-step edge and so keeps the dependency graph acyclic (no deadlock). observables reaches book both ways at once — the post-update ladder (current) for the spread, and the pre-update depth (lag) the arrivals responded to.

Partition wiring

The partition dependency graph, derived statically from the stub’s BuildStub wiring by pkg/graph. Solid arrows are within-step params_from_upstream wiring (which imposes a computation order); dashed arrows leaving a shaded past-copy node are lag reads of a partition’s committed state from an earlier step — drawn as separate source nodes so the graph stays a DAG.

flowchart TB
  n0["activity"]
  n1["flows"]
  n2["book"]
  n3["observables"]
  n2past["book"]
  n0 -->|activity| n1
  n2past -.->|book_partition| n1
  n1 -->|flow| n2
  n2 -->|book_now| n3
  n1 -->|flow| n3
  n2past -.->|book_partition| n3
  classDef pastcopy fill:#F5F5F5,stroke:#4a7ba6,color:#000;
  class n2past pastcopy;

Ingests (in the stub: nothing)

The stub is data-free — every input is a literal constant in stub.go, with the arrival-damping exponent damping_gamma exposed as the one swept driver. In the downstream application the model consumes a live Binance BTC/USD depth-diff and trade feed, from which per-level arrival, cancellation and marketable counts are inferred; the damping exponent is then fitted to the market’s depth/arrival correlation and the rest of the flow parameters to the pooled count correlations.

Assumptions

Validity regime

Failure modes

Question answered

Given an order-flow regime (arrival, churn, cancellation and marketable-order intensities) and an activity-dependent arrival-damping strength, what market state does the book settle into — its resting depth, its spread, and in particular the sign and strength of the coupling between resting depth and arrival flow, the market’s self-stabilising brake?

Generative behaviour under test

stub_test.go asserts, beyond “it runs”: 1. Harness — no NaNs, correct state widths, no params mutation, no statefulness residue across a repeated run (simulator.RunWithHarnesses). 2. Structural invariants — every flow component non-negative; resting depth non-negative on every level; swept volume non-negative; the spread strictly positive and never above the one-sided sentinel; the cancellation-clip count within [0, 2·levels]. 3. Correct direction of the headline response — stronger activity-dependent damping (higher damping_gamma) drives the ensemble-mean correlation between resting depth and limit arrivals more negative. This is the model’s reason to exist — the stability brake the downstream calibrates — and the assertion that would catch an inverted damping response.

The expected-behaviour suite (behaviour_test.go) adds named, plain-language response claims, each with the observed number emitted by the test run into the Observed behaviour table below (never hand-typed). This model is purely structural — its decision layer (order placement, execution and the calibration loop) lives entirely in the downstream trading/inference application, so the stub has no in-stub actionable lever. The suite is instead comprehensive on the structural drivers the world sets: the damping brake (headline), limit-arrival intensity deepening the book, marketable-order rate thinning it and widening the spread, marketable-order size sweeping more volume, and quote churn raising cancellations — one claim per major mechanism.

Observed behaviour

Every row below is one bound object: a plain-language response claim, the test subtest that enforces it, and the number that test produced (ensemble values rounded to 2 dp). Nothing here is hand-written — the claims and their numbers are emitted by TestLimitOrderBookExpectedBehaviour (via go run ./cmd/model-graphs), so a claim cannot drift from its test or its result. If the model’s behaviour changes, either the binding test fails (a claim’s assertion broke) or TestCardsUpToDate fails (a number moved) — a broken claim cannot reach the card silently.

Response claim Enforced by Observed
Stronger activity-dependent damping drives the depth/arrival coupling more negative (headline driver) TestLimitOrderBookExpectedBehaviour/stronger_damping_makes_the_depth_arrival_coupling_more_negative ensemble-mean corr(resting depth, limit arrivals) — γ=0.0 0.41 · γ=0.45 -0.18 · γ=0.9 -0.47
Higher limit-arrival intensity deepens the resting book TestLimitOrderBookExpectedBehaviour/higher_arrival_intensity_deepens_the_book ensemble-mean resting depth (lots) — limit_rate=2.0 77.32 · 3.381 231.73 · 5.0 453.41
Heavier marketable-order flow thins the resting book TestLimitOrderBookExpectedBehaviour/heavier_marketable_flow_thins_the_book ensemble-mean resting depth (lots) — market_rate=0.6 233.49 · 1.2 227.57 · 2.4 214.20
Heavier marketable-order flow widens the bid–ask spread TestLimitOrderBookExpectedBehaviour/heavier_marketable_flow_widens_the_spread ensemble-mean spread (ticks) — market_rate=0.6 2.04 · 1.2 2.18 · 2.4 2.63
Faster quote churn raises the cancellation count TestLimitOrderBookExpectedBehaviour/faster_quote_churn_raises_cancellations ensemble-mean cancellations per step (lots) — churn_rate=1.0 26.62 · 1.9 45.66 · 3.0 55.43
Larger marketable orders sweep more volume from the book TestLimitOrderBookExpectedBehaviour/larger_marketable_orders_sweep_more_volume ensemble-mean swept volume per step (lots) — market_size=2.0 2.45 · 4.0 4.67 · 8.0 9.41

Bespoke extensions

There are none staged for promotion, and that is the finding. Every other catalogue entry lifts custom simulator.Iteration code from its downstream repo; this one cannot, because the downstream repo (cryptobook) contains no bespoke Go at all — its entire forward model is written as stochadex configuration and run by the engine with no toolchain. So the relationship the catalogue usually has is inverted here: declarative.yaml is (essentially) the config the downstream actually runs, and the four Go iterations in this folder (activity.go, flows.go, book.go, observables.go) are a faithful re-expression of it in the SDK, kept identical to the data by the equivalence test.

That makes the promotion triage decidable a priori and in the affirmative: a serious, market-calibrated model runs as pure data, so the engine is not missing a capability here (a category-1 answer, established without needing recurrence). The bespoke Go is a convenience form, nothing more — which is why the equivalence test can hold the two bit-identical (~1e-12): the stochastic partitions draw from the same rng.New(seed) stream, in the same order, that the expression evaluator does. This family of order-book configs is, separately, the evidence that drove two engine primitives into core (scan, and a zero-width slice); see models/CONVENTIONS.md.

Downstream

Data ingestion (a live Binance depth/trade recorder), calibration of the damping exponent to a market correlation, simulation-based inference (SMC, likelihood surfaces), and the analysis that turns the model into market-stability claims live in the project repo:

https://github.com/umbralcalc/cryptobook