Mohamed El Khoudimi · Market microstructure

Avellaneda-Stoikov audit

What Happens When Avellaneda-Stoikov Meets a Real Order Queue?

An empirical audit of queue position, adverse selection, and optimal market making.

  1. What a market maker does It keeps offering to buy slightly below the market price and sell slightly above it, earning the difference. Its risk is being left holding shares when the price moves.
  2. What Avellaneda-Stoikov solves A well-known 2008 model (AS for short) that tells a market maker where to set those two prices, based on how many shares it already holds and how much risk it will take.
  3. What the queue is On a real exchange, orders at the same price wait in line. The order that arrived first is filled first.
  4. Why it could break the model AS assumes every order at a given price has the same chance of being filled. If your place in line matters more than your price, the model's advice can be wrong in ways it cannot see.

I set out to measure that. First I reproduced the paper's published results, which meant identifying a rule the paper never states. Then I built and tested a simulator that can switch the queue, and two other missing effects, on and off one at a time.

Where it stands: the replication is complete and the simulator is tested. The real-market experiment has not run yet, and nothing on this page claims its result.

Area
Market microstructure, quantitative research
Data
5 Nasdaq stocks from LOBSTER, 2,110,860 exchange messages checked1. Simulator tested on synthetic order flow.
Validation
Exact calculation, automated tests, pass marks fixed before results
01The idea

Two orders at the same price are not the same order

Picture a line at a counter where everyone wants to buy at $100.00. When a seller arrives, the person at the front is served first. If you joined late and the seller only has a few shares, they run out before they reach you.

Real exchanges work like that line. The AS model does not have one. It gives every buyer at $100.00 the same chance of being filled, whether they are first or fortieth.

Interactive illustration · real queue rule, example quantities

One price level, first come first served

Move your place in line and the size of the incoming sell order. The queue decides whether you trade. The AS model would give the same answer in every position.

The sell order clears the 24 shares ahead of you, then 6 of your 10.

Cancellations make this harder to measure. When shares at your price are cancelled, historical data does not say whether they were ahead of you or behind you. So I carry three answers through every queue result:

If 8 shares cancel, spread in proportion to the queue, about 18.7 remain ahead of you on average.

How the project uses this

"All behind me" gives the fewest fills and "all ahead of me" the most, so the true answer lies between them. "Spread evenly" is the main estimate. If a conclusion changes direction between the two extremes, I do not report it as a finding.

02The question

I wanted to know whether AS quotes lose money for reasons the model cannot see, and how much each reason costs

The model leaves out three things every market maker deals with. I label them A1, A2 and A3 throughout.

The three effects the AS model leaves out
EffectThe model assumesIn a real market
A1 Queue positionYour chance of a fill depends only on how far your price is from the market priceIt depends on how many shares are ahead of you at that price
A2 Adverse selectionBeing filled tells you nothing about where the price goes nextFills tend to arrive just before the price moves against you
A3 CompetitionYou quote alone and nobody reactsOther traders join or cancel when your order changes the line

My hypothesis. Once queue position and adverse selection are present, AS systematically misprices its quotes. The cost can be measured, split between the three effects, and partly recovered with a corrected rule.

What would count as success. A cost for each effect, in basis points (hundredths of a percent of the value traded), with confidence intervals, holding up across five stocks.

What would count against it. Small costs, or results whose direction depends on the cancellation assumption. I decided in advance to publish that outcome as prominently as a positive one.

Designed so it can fail

The replication test, its three candidate rules and its pass mark were committed before any number was computed5.

For the real-market experiment, a pre-registration is drafted. It must be committed and tagged before any real-market result is computed. It is not yet in force.

03The model

How Avellaneda-Stoikov sets its quotes

The model does two things. When you already hold shares, it lowers both prices so you are more likely to sell, and when you are short it raises them. It also sets how far apart the two prices are, wider when you are more cautious or have more time left.

Try it with the paper's own settings. At the highest risk setting and a large position, the model asks for a chance of being filled that is above 100%, which is impossible. The paper does not say what its simulation did then, and that gap is where my replication work started.

Interactive · the paper's formulas and settings

Where the model quotes, and the fill chance it implies

With no inventory, the quotes sit symmetrically around the mid-price.

Risk aversion γ (the paper's three settings)
The mid-price is the midpoint between the best buy and sell prices. Settings from the published paper2: mid-price 100, σ = 2, k = 1.5, A = 140, dt = 0.005. The chance of a fill in one time step is λ(δ)·dt with λ(δ) = A·exp(−kδ), where δ is the quote's distance from the mid-price.
Technical detail: the equations and the simulation rule

Reservation price (the model's inventory-adjusted fair price) and total spread, published equations 29 and 30, with inventory q, risk aversion γ, volatility σ and remaining time T − t:

r(s, q, t) = s − q·γ·σ²·(T − t) δa + δb = γ·σ²·(T − t) + (2/γ)·ln(1 + γ/k) bid, ask = r ∓ (δa + δb)/2 λ(δ) = A·exp(−k·δ)

The numerical experiment runs 1,000 paths of 200 steps2. Each step, the bid and ask fill independently with probability λ(δ)·dt at the current quotes, then the mid moves by ±σ√dt. The symmetric benchmark quotes the time-averaged AS spread around the mid. Profit is terminal cash plus inventory marked at the terminal mid.

The unstated case: when λ(δ)·dt > 1, a Bernoulli probability is undefined. In my first seeded runs this happened for the inventory strategy in Tables 1 and 3, with raw values up to 7.593.

The full Hamilton-Jacobi-Bellman derivation, including the exponential-utility transformation and the frozen-inventory approximation, is in docs/DERIVATION.md.

04The experiment

Reproduce first, then test one assumption at a time

An audit is only credible if it starts from the model as published. So the plan has a fixed order, and each step has to pass before the next one means anything. The label under each step says what kind of evidence it rests on today.

  1. Step 1

    Check the data

    Check every exchange message against LOBSTER's order-book snapshot, and stop on anything unexplained.

    Real market data Verified
  2. Step 2

    Reproduce the paper

    Match all three published tables, and identify the rule behind them.

    Exact calculation Verified
  3. Step 3

    Build the simulator

    Replay the order book with three independent switches: A1, A2 and A3.

    Synthetic order flow Synthetic only
  4. Step 4

    Measure each effect's cost

    Split AS's shortfall across A1, A2 and A3, against the best rule found within a fixed family.

    Pipeline tested; real run not done Real-data test pending
  5. Step 5

    Derive a correction

    A quoting rule with queue and adverse-selection terms, tested once on data held back for the purpose.

    Derived; held-back data untouched Not evaluated
  6. Step 6

    Make it reproducible

    One-command reproduction, a benchmark for other strategies, a container recipe and automated checks.

    Checks pass on GitHub; container runs offline; market path not run Partially verified

In the repository these steps are the milestones M0 to M5.

Diagram · experimental design

Eight worlds, one per combination of switches

Running a strategy in all eight lets me split its loss exactly across A1, A2 and A3, including the part that only appears when two effects combine.

The split uses Shapley values: each effect's average contribution over every order in which the switches can be turned on.

Diagram · planned split of each trading day

Each day is split in time before anything is fitted

The last 30% of every day is locked. The measurement never replays it, and the one evaluation allowed to open it records every access.

Planned for the LOBSTER sample11: 09:30 to 16:00 per stock, cut into 300-second episodes. Synthetic test data uses the same mechanics and is never mixed with market data.
05What I found

Research status

  • CompleteOriginal-paper replication. All three published tables reproduced under one identified rule4.
  • ValidatedSimulator correctness. Reduces exactly to the paper's model; market-mechanics tests pass on synthetic order flow6.
  • Synthetic onlyEnd-to-end measurement pipeline. Validated on synthetic test data. Its economic outputs are not findings8.
  • ImplementedCorrected policy. Derived and implemented; reduces to AS when both corrections are switched off7.
  • PendingReal-market attribution (measuring each effect's cost on real data). Not run.
  • PendingHeld-out real-market economic result. Not run; the held-back data is untouched.

The published results come from a capped fill rule and the paper's time step

  • I reproduced all three published tables, under one specific reading of the paper.When the chance of a fill is capped at 100%, all 27 printed values sit within the range chance alone would produce. The largest gap is 1.88 standard errors; I had fixed the cut-off at 3.11 in advance4.
  • The natural alternative rule fails.Treating fills as random arrivals, 1 − exp(−λ·dt), puts 7 of 27 values outside that range, one of them 38.5 standard errors away. It lowers expected profit by about 12% in Table 14.
  • The numbers belong to the paper's time step, not to the continuous-time model.With the same capped rule, halving the time step puts 6 values outside the range, and a five-times finer step puts 11 outside4.
  • The paper does not state the capping rule. The evidence supports it; it is my inference, not the authors' documented procedure.

How I tested it. Comparing the paper with one random simulation of my own would compare noise with noise. Instead I calculated each candidate model's exact expected results, including how much they vary. Then I asked whether each printed number is a plausible outcome of the paper's own 1,000-path experiment2. The test, the three candidate rules and the cut-off were committed before anything was computed5.

Result · exact calculation, no market data

How far each published number sits from the model's exact expectation

Capped fills at dt 0.005: all 27 values fall inside the band.

Each mark is one printed value from Tables 1 to 3: profit mean and spread, final inventory mean and spread for both strategies, and the variance ratio for each table. Zero means the printed value equals the exact expectation. The shaded band is ±3.11 standard errors of a 1,000-path estimate: a two-sided 5% test, Bonferroni-corrected for 27 comparisons4. Marks beyond ±6 are pinned to the edge and labelled. Data: sensitivity.json, clean commit e77692c4.
Show the values as a table

The simulator reduces to this result

With all three switches off, my simulator should behave exactly like the paper's model. It does: across four configurations of 1,500 paths each, its profit and inventory statistics match the exact capped-rule results within 4.5 standard errors6. That test connects every later experiment back to the paper.

I derived a corrected rule, and it disagreed with my own spec

Adding queue-dependent fills and an expected adverse price move to the model's optimisation gives a rule that reduces to AS when both additions are off. The derivation put the adverse-selection term with the opposite sign to the formula my specification had guessed: the side with more adverse selection is quoted further away7. I kept the derivation. The rule has not been evaluated on market data.

06What failed

Where the evidence pushed back

Every one of these results is kept in the repository. None of the original criteria was loosened to make a test pass.

Negative result

My first replication did not pass

What happened
My first test compared the paper with one seeded simulation. Tables 1 and 3 could not even run under a strict reading: the model asked for fill chances up to 7.59 (759%), with 10,259 such cases in the Table 3 run3.
What I changed
I did not search for a lucky random seed. I built the exact-calculation test above, fixed its rules and cut-off first, and let the data choose between them.
Outcome
The original failure is preserved and still reported as "not reproduced" under its own terms3.
Negative result

A pass mark from my own spec was too strict

What happened
My spec required one headline ratio to land within 10% of the paper. In Table 1 the printed ratio (3.70) is 14.2% from the exact ratio (4.23), yet only 1.64 standard errors away4.
Why it matters
The paper's own sampling error on that ratio is about 8%4. A ±10% band would reject the true model often.
Outcome
I documented it as a proposed amendment, not a silent change.
Synthetic only

The measurement pipeline ran, and its numbers mean nothing yet

What happened
I ran the full measurement on 5 synthetic stocks to test the machinery. The benchmark rule it compares against converged in 0 of 560 fits, because I gave its search a deliberately tiny budget of 30 evaluations8.
Why it matters
An under-fitted benchmark makes AS's measured shortfall meaningless. The competition effect also changed direction between the cancellation extremes, so the pipeline withheld it, as designed8.
Outcome
None of those economic values appears on this page. The real-market configuration treats non-convergence as a hard failure.
Blocked

The real-market run has not happened

What happened
The LOBSTER files were checked in an earlier environment1. The environment where I built the simulator could not reach the data host15.
Outcome
Every simulator and measurement result is on synthetic order flow. My starting hypothesis is still untested.
07Engineering

A replay engine where fills have to be earned

The simulator replays the order book in 100-millisecond batches12. Each batch runs the same six steps, and any of the three effect models can be swapped without touching the loop.

Diagram · one 100 ms batch

The six steps every batch runs, in order

  1. Read the bookOnly events before this batch. Nothing from the future.
  2. Ask for quotesInventory cap applied, prices kept passive.
  3. Wait out latencyA changed price is cancelled and re-queued at the back.
  4. Match fillsA1 decides how fills happen. A3 changes cancellations ahead of me.
  5. Value each fillA2 decides how much price move the fill carries.
  6. Mark and checkProfit marked to the mid-price; the accounting identity is asserted.

Fill mechanics

  • Trades at my price use up the shares ahead of me first.
  • A large order that trades through my price fills me only up to its own size.
  • A repriced order goes to the back of its new line.
  • An order the market moves through is suspended, not filled at an impossible price.
  • Hidden-order trades never fill me. That can only lower fill counts.

Modelling decisions

  • Adverse selection is added only where the fill model does not already contain it, so every combination of A1 and A2 means the same thing.
  • Competition acts through cancellations ahead of me, scaled by how strongly other traders were measured to react to depth.
  • Prices are whole ticks and timestamps whole nanoseconds.
  • Every statistical fit that fails to converge raises an error.

Research plumbing

  • Every run writes a manifest: git commit, configuration fingerprint, input checksums, seeds, environment.
  • Runs refuse uncommitted code unless the exception is recorded.
  • Random seeds come from explicit keys per stock, episode and strategy.
  • Parallel workers change speed, never results.
Technical detail: module map
Module responsibilities
ModuleResponsibility
sim/moments.pyExact finite-state recurrence for profit moments up to fourth order, per inventory state
eval/sensitivity.pyThe pre-declared replication test and its z-scores
sim/engine.pyThe batch loop, latency, post-only quoting, cancel and replace
sim/fills/{poisson,queue}.pyA1 off and on; three cancellation-attribution policies
sim/flow/*, sim/competition/*A2 and A3 off and on
calibration/*Fill intensity, queue-fill logistic, markouts, depth elasticities; generalized linear models fitted by iteratively reweighted least squares
attribution/{grid,shapley,run}.pyThe eight-world grid, exact Shapley values, walk-forward fitting of the benchmark policy
eval/bootstrap.pyStationary block bootstrap with Politis-White block length
strategy/corrected.pyThe derived correction
eval/holdout.pyThe one-time held-out evaluation and its access ledger
08Correctness

What the tests are there to catch

The suite has 146 passing tests, plus linting and strict type checking9. The count matters less than what each group would catch.

Exact mathematics

  • The exact calculation matches brute-force enumeration of every path.Catches a wrong formula for how much results vary.
  • The symmetric strategy matches its closed form.Catches probability leaking out of the calculation.

Reduction to the paper

  • With all switches off, the simulator matches the paper's model exactly.Catches a simulator that drifts from the model it claims to test.
  • The corrected rule reduces to AS with both corrections off.Catches a correction that changes the baseline.

Market mechanics (property tests)

  • Cash plus inventory at the mid-price equals the profit breakdown at every step.Catches accounting drift.
  • No fill occurs through the other side of the book.Catches impossible trades.
  • Queue position only improves until a reprice, which goes to the back.Catches fills that were never earned.
  • Fill counts order as all-behind ≤ spread-evenly ≤ all-ahead.Catches bounds that do not bound.

Determinism

  • Fixed seeds give byte-identical fill sequences, checked against stored fingerprints.Catches silent behaviour changes.
  • All eight worlds share the same random numbers.Catches the seeding bug below.
  • Worker count does not change any output.Catches order-dependent parallel code.
  • A fresh one-command run from a clean checkout reproduced my committed synthetic run byte for byte14.Catches hidden state between runs.

Research guards

  • The three effects' contributions always sum to the total.Catches a broken decomposition.
  • Held-back episodes are never replayed by the measurement.Catches data leakage.
  • The held-back evaluation refuses a second run unless a reason is recorded.Catches quiet re-testing.

GitHub Actions runs linting, strict type checking and the full test suite on every push. All 8 runs up to commit 473fedd passed9.

09Bugs I caught

Two bugs that would have produced believable wrong answers

Different random numbers in every world

Symptom
None visible. Every run completed normally.
Why it invalidated the experiment
The measurement compares the eight worlds against each other. With different random streams, chance differences would have been reported as the cost of queue position or adverse selection.
Root cause
NumPy's SeedSequence.spawn() advances an internal counter. Calling it once per run gave each world a different random stream.
Fix
Seeds now come from explicit keys per stock, episode and strategy, identical in every world13.
Regression protection
A test checks that two worlds which differ only in an inactive switch produce identical fills. The old code fails it13.

Five stocks, one name

Symptom
The log of my first full synthetic run listed every session under the same name, SYN.
Why it invalidated the experiment
Results are filed by stock and episode, so the five sessions overwrote each other. The output looked complete but mixed records from different stocks.
Root cause
The synthetic data generator hard-coded the name.
Fix
The name is passed through, and loading now rejects duplicate names. I discarded the run and recorded why in the decision log13.
Regression protection
A pipeline test asserts that each stock is measured separately13.
10Evidence

Where the evidence stops

Status of each claim and its basis
ClaimStatusBasis
LOBSTER samples pass message-by-message checks against exchange snapshotsVerifiedReal data, 2,110,860 messages, earlier environment1
Published tables are consistent with capped fills at dt 0.005VerifiedExact calculation, pre-declared test4
The authors used capped fillsSupported, not confirmedInference from the data; the paper is silent
Simulator reduces to the paper's modelVerifiedIntegration test against exact results6
Simulator mechanics hold on order-book flowSynthetic onlyProperty tests on synthetic sessions6
About 21 s to replay one trading dayEstimated, not measuredProjected from a synthetic session10
How much A1, A2 and A3 each cost ASReal-data test pendingPipeline works on synthetic data; no real run yet8
Corrected rule improves on ASNot evaluatedHeld-back data untouched
Automated checks on GitHubVerified8 of 8 runs passed up to 473fedd9
One-command reproductionPartially verifiedSynthetic path run end to end from a clean checkout14; market path not run
Container imageVerifiedBuilt and run with networking off at 473fedd: all tests pass and the synthetic reproduction completes14
Live crypto order-book capturePendingCollector built and tested locally; the one-hour live run is outstanding
11Reproduce

Check it yourself

You need Python 3.11+, uv and a clean git checkout. Every command writes a manifest under results/.

Install and run the tests Run
uv sync --locked
uv run pytest -q
The replication test (deterministic, about 5 minutes4) Run
uv run asaudit m1-sensitivity
The original seeded comparison (exits 1: "not reproduced" is its recorded outcome) Run
uv run asaudit replicate --config configs/replication/qf2008.toml --reference tests/golden/as2008_table_qf2008.json
Everything, on synthetic order flow Run
uv run asaudit reproduce --paper --fixture --workers 4
Everything, on the LOBSTER sample (needs network access and a committed pre-registration) Not yet run
uv run python scripts/fetch_lobster_sample.py
uv run asaudit reproduce --paper --workers 8
Score your own strategy beside AS Run
uv run asaudit benchmark --quoter benchmarks.example_quoter:make --config configs/ablation/fixture.toml
12Sources

Where every number comes from

Each numbered mark on this page points here. Every quantitative value is read from these files at build time, and the build fails if the page disagrees with them.

  1. Market-data checks: docs/reports/M0.md and the five run manifests in docs/evidence/m0-normalization/ (message counts summed).
  2. Published parameters and tables: tests/golden/as2008_table_qf2008.json, transcribed from Avellaneda and Stoikov, Quantitative Finance 8(3), 2008, section 3.3.
  3. First seeded comparison: docs/evidence/m1-qf2008/run/comparison.json and its report.
  4. Replication study: sensitivity.json, its manifest (clean commit, 317 s elapsed) and report.
  5. Test declared before computing: commit 6adb9a3 and the entry "M1 bounded sensitivity protocol" in docs/DECISIONS.md.
  6. Simulator checks: test_grid_reduces_to_as.py and test_engine_properties.py.
  7. Corrected rule: derivation (section 3 on the sign) and test_corrected.py.
  8. Synthetic pipeline run: docs/evidence/m3-fixture/run/ (pco_folds.json, manifest.json, attribution.json) and its report.
  9. Test count: STATE.md. GitHub checks: the "verify" workflow runs.
  10. Replay-time projection: docs/reports/M2.md (measured 3.6 s on a synthetic hour, projected to a full day).
  11. Planned split: configs/ablation/lobster.toml and split_episodes in attribution/run.py.
  12. Engine design: sim/engine.py and sim/fills/queue.py.
  13. Bugs: test_common_random_numbers.py, test_ablation_pipeline.py, and "Invalidated fixture run" in docs/DECISIONS.md.
  14. Reproduction runs: site/ACCURACY_AUDIT.md, sections "Reproduce commands" and "Container run".
  15. Data access: "M2 implementation decisions" in docs/DECISIONS.md.
13Next

The decisive experiment

Turning the tested machinery into evidence takes four steps, in this order:

  1. Replay one real LOBSTER day through all eight worlds. Measure the real replay time, and confirm the market-mechanics tests hold on real order flow.
  2. Commit and tag the pre-registration before computing any real-market result.
  3. Measure all five stocks with a search budget large enough for the benchmark rule to converge. Report adverse selection first, with the cancellation extremes.
  4. Only if those results are stable, open the held-back data once to test the corrected rule.
14My role

What I did

I defined the research question, the plan and its pass marks. I built the data checks, the replication and its exact-calculation test, the event-driven simulator, the calibration and measurement pipeline, the corrected quoting rule and the reproduction tooling. I wrote the tests that guard them, found and fixed the bugs described above, discarded the run they invalidated, and documented the results that did not go my way.