Skip to content

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

25 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

TrueFill — what queue position does to a market-making backtest

CI License: MIT

Almost every market-making backtest fills your resting order when a trade prints at your price. Real exchanges make you wait in a queue — you trade only after everything ahead of you has traded or cancelled. This measures what that one assumption is worth.

  • Always at touch: -98.06 ticks under optimistic fills, -168.13 under queue-aware fills (difference +70.07, 95% CI [+29.47, +109.49]). It executes 2.7x more volume when the queue is ignored.
  • Inventory skew: +213.87 ticks under optimistic fills, +2.78 under queue-aware fills (difference +211.09, 95% CI [+205.09, +217.21]). It executes 2.9x more volume when the queue is ignored.
  • Learned (FQI): -50.28 ticks under optimistic fills, -34.56 under queue-aware fills (difference -15.72, 95% CI [-45.23, +12.66]). It executes 6.8x more volume when the queue is ignored.

Fill model comparison


Results

200 held-out test episodes, queue-aware fills, PnL in ticks per 5-minute episode. "vs baseline" is the paired difference against inventory-skew, ✓ = significant after Holm–Bonferroni.

Policy PnL (ticks) 95% CI Fills Inv. RMS Edge/lot 5s markout vs baseline
Inactive (control) +0.00 [+0.00, +0.00] 0 0.0
Always at touch -168.13 [-202.43, -134.95] 249 26.9 +0.374 +0.106 -170.91 (✓)
Fixed spread, 2 ticks +3.52 [-0.24, +7.09] 9 2.6 +0.548 +0.915 +0.74 (n.s.)
Inventory skew +2.78 [-1.58, +6.95] 167 3.7 +0.431 +0.255 baseline
Avellaneda–Stoikov -18.63 [-28.80, -9.12] 33 6.8 +0.466 +0.188 -21.41 (✓)
Learned (FQI) -34.56 [-45.43, -24.47] 34 8.0 +0.159 -0.268 -37.34 (✓)

Same policies, both fill models, identical seeds:

Policy Optimistic fills Queue-aware fills Difference 95% CI Fill-volume inflation
Always at touch -98.06 -168.13 +70.07 [+29.47, +109.49] 2.72×
Fixed spread, 2 ticks +9.44 +3.52 +5.91 [+1.35, +10.36] 1.65×
Inventory skew +213.87 +2.78 +211.09 [+205.09, +217.21] 2.91×
Avellaneda–Stoikov -13.79 -18.63 +4.84 [-9.47, +18.51] 2.12×
Learned (FQI) -50.28 -34.56 -15.72 [-45.23, +12.66] 6.78×

Inventory-skew is the clean case: a solid earner under optimistic fills is statistically indistinguishable from zero once the queue is modelled. Nothing about the policy changed.

Two caveats. Always at touch loses more under queue-aware fills — optimistic filling hands it extra volume with a healthier markout, while queue-aware filling leaves it holding mainly the trades that ran it over. And the learned policy was trained under queue-aware fills, so its optimistic column is off-distribution and not a like-for-like read.

Every number here is injected from results/raw/*.json by a script. None are hand-typed.


What's built

src/lobsim/
  book.py         price-time priority matching engine, exact queue-position tracking
  flow.py         order flow: zero-intelligence + Hawkes + latent fundamental
  engine.py       discrete-event loop, post-only quoting, exact PnL attribution
  features.py     18 microstructure features in 4 ablatable groups
  agents/         5 rule-based baselines + fitted Q-iteration policy
  stats.py        block bootstrap, paired tests, Holm correction, deflated Sharpe
  validation.py   stylized-fact estimators

Engine. Each price level is an insertion-ordered dict, so the level is the FIFO queue: front-of-queue and mid-queue cancellation are both O(1). Mid-queue cancels are the most common event in a real book. Agent orders carry a volume_ahead counter updated incrementally and cross-checked against full recomputation by a Hypothesis property test.

Two things it won't let a backtest get away with. Re-quoting costs queue priority — changing your price cancels and rejoins at the back. Post-only — a quote that would cross is rejected and counted, never silently turned aggressive.

Market. Zero-intelligence flow + Hawkes market orders + a latent fundamental that aggressive flow chases. That last part makes flow informed, which is what gives the market maker something to lose. Pure zero-intelligence was built first and rejected on measurement: the mid moved 1–4 ticks per episode, leaving no inventory risk and no adverse selection (D3).

Policy. Fitted Q-Iteration over gradient-boosted trees, 16 actions (each side joins the touch, rests 1–2 ticks back, or pulls). Batch RL — no learning rate, no replay buffer, no divergent runs.


Simulator fidelity

Measured on an agentless market so the agent can't flatter it. Targets are bands from the empirical microstructure literature.

Stylized fact Measured Target band
return excess kurtosis 30.2230 [0.5, 50] pass
volatility clustering lag1 0.2370 [0.02, 0.6] pass
volatility clustering lag10 -0.0068 [0, 0.4] FAIL
raw return acf lag1 -0.1139 [-0.35, 0.05] pass
variance ratio 10 0.9827 [0.7, 1.3] pass
variance ratio 30 1.1143 [0.7, 1.3] pass
median spread ticks 1.0000 [1, 2] pass
mean touch depth lots 17.6072 [5, 60] pass
order size tail index 1.8726 [1.4, 3] pass
market order sign acf lag1 0.2646 [0, 0.5] pass
depth profile hump index 0.0000 [1, 4] FAIL

9 of 11 pass. Failures are analysed in Limitations.

Validation

The variance ratio matters most: a mean-reverting mid would pay a market maker for bearing no risk and make every downstream number an artefact.


Where the money goes

PnL decomposition

PnL splits into spread capture (edge on each fill, marked at execution) and inventory PnL (carrying a position while price moves). The split is an exact identity accumulated one book mutation at a time; the residual is asserted to be zero across seeds and policies.

Markouts

Markouts show how much captured edge survives the next few seconds. High spread capture with a negative markout means you're being picked off.

PnL distribution


Ablations

Ablation Variant PnL (ticks) Δ vs reference 95% CI
Feature groups no queue -55.84 -16.07 [-37.09, +4.51]
Feature groups no flow -63.60 -23.83 [-51.96, +2.97]
Feature groups book only -91.36 -51.59 [-102.22, -2.15]
Cancel position (at_touch) back-loaded -154.38 -6.63 [-63.72, +47.17]
Cancel position (inventory_skew) back-loaded -0.58 -2.13 [-9.60, +5.83]
Cancel position (fqi) back-loaded -40.87 -1.10 [-18.92, +17.49]
Q estimator single (vs double) -36.78 +2.99 [-13.80, +20.41]

Q-value target inflation: 1.85× with the double estimator versus 1.85× with a single one.

Ablations

Run on the first 100 test seeds (each variant needs its own dataset, fit and evaluation), so these CIs are wider than the headline's. Seeds are a prefix of the same held-out set, never a re-draw.

Feature ablation point estimates fall monotonically as information is removed — queue −16 ticks, flow −24, book-only −52 — but only book-only clears significance (p = 0.046). Underpowered at 100 episodes; the ordering is suggestive, not proof.

The double-estimator ablation is a null result: no change in Q-value inflation (1.8519 vs 1.8516), no PnL change (p = 0.73), despite provably correcting the bias on a synthetic diagnostic. The inflation metric isn't a clean bias measurement once the discount is doing real work — INTERVIEW Q10.

Cancellation position is the least verifiable assumption in the model and controls how fast the queue ahead of you evaporates. The default (UNIFORM) is the more generous of the two, so the headline isn't manufactured by a pessimistic choice.


Selection-adjusted performance

Policy Sharpe (per episode) Selection benchmark Deflated Sharpe Trials
Fixed spread, 2 ticks +0.134 +0.000 0.955 1
Learned (FQI) -0.459 +0.140 0.000 24
Inventory skew +0.089 +0.000 0.886 1

The learned policy is deflated by 24 configurations — every FQI variant evaluated on validation seeds across development, not the 4 in the final grid.


Reproduce

Python 3.11+ and uv. No GPU.

git clone https://github.com/bradca0/lobsim.git && cd lobsim
make setup
make test            # 263 tests, coverage gate on core logic
make lint typecheck  # ruff + mypy --strict
make reproduce       # regenerates every number and figure above

make reproduce runs: validation → training (train seeds only, selection on validation seeds) → backtests on held-out seeds under both fill models → ablations → statistics → figures → README injection. Raw outputs land in results/raw/, each stamped with the git revision that made it.

Budget ~1 hour on an 8 GB M1 Air. Binding constraint is memory, not cores — each worker re-imports numpy/scipy/sklearn, so workers are capped at 3. Six drove free memory to ~65 MB and stalled the run.

Every episode is fully determined by its seed; parallel and serial paths give identical answers.

Generated from commit fe13745 on macOS-26.5.2-arm64-arm-64bit. 200 held-out test episodes (seeds 1000–1199), 60 agentless episodes for validation. Backtests took 5.8 minutes.


Limitations

The learned policy loses money and loses to a five-line heuristic. It beats naive always-at-touch by a wide margin and still loses to InventorySkew. Diagnosis is signal-to-noise, not model capacity: per-step reward has σ ≈ 7 ticks while a good-vs-bad decision is worth a small fraction of a tick, and FQI has to resolve that from ~120k transitions across 16 actions. Persistent exploration was worth a large improvement over i.i.d. exploration, so the machinery works — it's the SNR that doesn't. INTERVIEW Q15 lists what I'd try next, in order.

Synthetic market, calibrated to stylized targets rather than estimated. Parameters aren't statistically identified; a different vector reproducing the same book shape would be equally admissible. Results are conditional on this regime — 1-tick spread, deep queue, where queue position matters most. The right falsification is real message-level data: replay a genuine book, place synthetic orders in the real queue, measure the same gap.

Two stylized facts fail. Depth profile is monotone decreasing instead of hump-shaped (re-measured by tick offset to rule out an artefact; unchanged). Volatility clustering dies within ~1s where real markets show slow decay. A two-timescale Hawkes kernel was implemented to fix it, tested against a pre-registered criterion, failed, and reverted — every setting that produced clustering collapsed the variance ratio (D8 has the sweep). Neither failure is on the causal path from queue mechanics to fill probability.

No latency, no fees. Quotes take effect instantly and there are no exchange fees or rebates. Maker rebates would lift every policy's PnL and change which are profitable — though not the direction of the fill-model result.

Single instrument, no hedging. Inventory is managed only by quoting and terminal liquidation.


Docs

  • DECISIONS.md — every non-obvious choice, alternatives, and why. Includes the ones implemented, measured, and reverted.
  • INTERVIEW.md — the 15 hardest questions about this repo, answered with pointers into code and results.

MIT licensed.

About

A limit order book simulator that separates real market-making edge from wishful thinking — turns out 98.7% of the backtest edge was just optimistic fills.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages