A backtesting framework for crypto market making, built around realistic execution modelling, inventory control, order-flow conditioning, and explicit maker/taker fee accounting, which is what actually decides whether a market maker is profitable.
A market-making simulator is only as honest as its execution and cost model, so those are what this project is built around rather than a P&L number.
| Fill model | FIFO with latency gating, queue position, and anti-crossing |
| Inventory control | Avellaneda-Stoikov reservation price, sized against the half-spread |
| Exchange fees | Maker and taker charged per fill, defaulting to the retail tier |
| Risk | Inventory, drawdown, and notional limits with a kill switch |
| Tests | 136, 82% coverage |
Inventory-aware quoting cuts inventory risk by 62% at no cost in fill rate. Measured against the symmetric baseline across 20 paired tapes: inventory RMS falls 62% and peak inventory 54%, on 20 tapes out of 20, while the fill rate is marginally higher and traded volume is within 4%. Under live risk limits the symmetric book breached its position limit on 4 of 20 tapes; the inventory-aware one never did. See Does inventory-aware quoting actually control inventory?.
Fees, not strategy logic, decide the sign of the result. A 5 bps half-spread captures roughly 10 bps per completed round trip against roughly 20 bps of retail fees, so passive market making needs a rebate venue or a genuine volume tier before the strategy question even arises. See Exchange fees decide the sign of the result.
What it does not claim is profitability. The available data is synthetic. The inventory result above survives that limitation because inventory dynamics are a property of the quoting rule and the fill engine rather than of the price process; a P&L comparison on the same data would not, and Sample output sets out why.
This project simulates a market maker posting resting limit orders on a crypto exchange, and evaluates strategy performance across three progressively more sophisticated approaches:
| Strategy | Description |
|---|---|
| V1 Naive | Fixed symmetric spread around mid, no inventory awareness |
| V2 Inventory-Aware | Avellaneda-Stoikov-inspired reservation price; widens spread and reduces size as position grows |
| V3 Order-Flow-Aware | V2 + momentum and order book imbalance signals; widens spread during toxic flow |
Key outputs: PnL gross and net of fees, Sharpe ratio, fill rate, adverse selection, inventory distribution, and a latency sensitivity study.
Posted bids and asks tracking mid, with fills marked, and the bid-ask spread below. This is the mechanism the simulator models; see Sample Output for why the P&L numbers from synthetic data should not be read as performance.
The strategies differ in one respect that matters for risk: V1 quotes symmetrically around the mid and never looks at its own position, while V2 shifts the quote centre against the inventory so that the side which reduces the position is always the more attractive one. Whether that shift does anything measurable is a question the simulator can answer, so it is answered here rather than asserted.
Design. Twenty independent synthetic tapes of 10,000 trades each. Every strategy sees the same tape, so the comparison is paired and within-tape. Each tape is run twice: once with the risk limits live, and once with them raised out of reach. The second run is not optional. The kill switch halts a strategy that breaches its limit, and a book stopped at tick 4,495 would otherwise report tidier inventory statistics than one allowed to keep trading for the full tape.
Left: one tape, inventory over time. Centre: inventory RMS for each of the 20 tapes, paired. Right: how often the position limit is breached as sessions lengthen.
V2 against V1, median across 20 tapes, limits lifted:
| Measure | V1 symmetric | V2 inventory-aware | Change | Tapes agreeing |
|---|---|---|---|---|
| Inventory RMS (BTC) | 0.0172 | 0.0065 | 62% lower | 20/20 |
| Peak absolute inventory (BTC) | 0.0387 | 0.0178 | 54% lower | 20/20 |
| Inventory half-life (ticks) | 1,343 | 514 | 62% lower | 20/20 |
| Fill rate | 39.1% | 39.4% | 0.6% higher | 17/20 |
| Volume traded (BTC) | 1.278 | 1.228 | 3.9% lower | 0/20 |
| Adverse selection (bps) | 2.713 | 2.698 | 0.6% lower | 13/20 |
| Gross P&L (USD) | 35.22 | 32.91 | 6.5% lower | 7/20 |
The reduction is not bought by trading less, which is the obvious confound: V2 shrinks the size of whichever side would worsen the imbalance, so it could in principle hold a smaller position simply by transacting less. It does not. Volume is 3.9% lower while inventory risk is 62% lower, and the fill rate is marginally higher. The cost appears in gross P&L at 6.5%, but on only 7 of 20 tapes, which is the honest way to state it: the P&L difference sits inside tape-to-tape noise, the inventory difference does not.
Under live limits, the symmetric baseline breached its position limit on 4 of 20 tapes and was halted, the earliest at tick 4,495. V2 and V3 never breached.
An unmanaged book is a random walk in inventory, so how far it travels grows with the square root of the session and its chance of touching a fixed limit rises with it. If inventory-aware quoting merely postponed the breach, the advantage would erode as sessions lengthen. It does not:
| Session length | V1 breach rate | V2 breach rate | V1 inventory RMS | V2 inventory RMS |
|---|---|---|---|---|
| 30 min | 10% | 0% | 0.0143 | 0.0068 |
| 1 hour | 25% | 0% | 0.0175 | 0.0063 |
| 2 hours | 85% | 0% | 0.0217 | 0.0070 |
| 4 hours | 85% | 0% | 0.0200 | 0.0069 |
V1's inventory RMS grows with the session; V2's is flat to three decimal places. V1's breach rate saturates at 85% rather than climbing to 100% because a breaching run halts, which also caps its measured inventory at the limit.
The same separation shows up in the half-life, and it is worth being careful about why. Fitting AR(1) to a finite random walk returns a coefficient just under one, so it reports a large but finite half-life which is really only a fraction of the observation window. Lengthening the tape distinguishes the two cases:
| Trades per tape | V1 half-life | fraction of tape | V2 half-life | fraction of tape |
|---|---|---|---|---|
| 5,000 | 750 | 0.15 | 325 | 0.065 |
| 10,000 | 1,108 | 0.11 | 631 | 0.063 |
| 20,000 | 5,319 | 0.27 | 476 | 0.024 |
| 40,000 | 6,882 | 0.17 | 705 | 0.018 |
V1's half-life tracks the window, so it is an artifact: the symmetric book has no reversion timescale because it has no mechanism that would give it one. V2's sits at a few hundred ticks regardless of session length, which is what a working control loop looks like. The test suite asserts this distinction directly, against synthetic AR(1) and random-walk series with known answers.
python experiments/inventory_control.py --tapes 20 --horizon-studyCaveat. This is a mechanism result, not a performance result. It holds on synthetic GBM tapes with no informed flow. Inventory dynamics are a property of the quoting rule and the fill engine, which is why this comparison means something where a P&L comparison on the same data would not, but real order flow is autocorrelated and adversarial in ways this tape is not.
trader/
├── data/ # Market data: download, load, reconstruct order book, compute features
├── simulator/ # Order primitives, FIFO fill engine, tick-by-tick event loop
├── strategy/ # Three strategy versions (common interface: BaseStrategy)
├── risk/ # Inventory tracking, FIFO PnL, drawdown, kill switch
├── analytics/ # Metrics (Sharpe, fill rate, adverse selection) and plots
├── experiments/ # Controlled studies; inventory control vs the symmetric baseline
├── tests/ # pytest suite (136 tests, 82% coverage)
└── backtester.py # CLI entry point
for each trade event:
1. Strategy → QuoteRequest (bid price, ask price, sizes)
2. QuoteRequest → Order pair → FillEngine
3. FillEngine.process_trade() → fills (FIFO + latency gate + anti-cross)
4. RiskEngine.on_fill() → update inventory, PnL, drawdown
5. Kill switch check → stop if limit breached
The fill engine simulates order queue position and network latency:
- Latency gate: order not eligible until
age >= latency_ms(default: 50ms) - Direction match: sell-aggressor trades fill bids; buy-aggressor trades fill asks
- Price condition:
trade.price <= bid.priceortrade.price >= ask.price - Queue position: fill only happens if
trade.qty > queue_position_fraction × order.size - Anti-crossing: bid and ask from the same quoting round cannot both fill on the same trade
- Expiry: orders expire after
max_order_lifetime_ms(default: 2000ms)
# Clone and install
git clone https://github.com/wooyoungcode/crypto-market-maker.git
cd crypto-market-maker
python -m venv .venv && source .venv/bin/activate
pip install -e ".[dev]"
# Run all three strategies on synthetic data (no download required)
python backtester.py --synthetic --compare-all
# Run on real Binance US data (no API key required)
python backtester.py --symbol BTCUSDT --start 2024-01-01 --end 2024-01-07 --strategy v3
# Latency sensitivity study
python backtester.py --synthetic --strategy v3 --latency-study
# Run tests
pyteststrategy total_pnl_usd realized_pnl_usd max_drawdown_usd sharpe n_fills n_orders fill_rate max_abs_inventory
v1 -1.1915 -2.7303 6.6071 -3.154 2044 3960 0.3960 0.024375
v2 -5.4177 -5.7484 7.2308 -25.375 2046 3960 0.3985 0.014265
v3 -11.0493 -11.0459 11.0621 -510.104 1948 3900 0.3846 0.001007
Reproduce with python backtester.py --synthetic --compare-all --fee-tier retail.
Every strategy loses money net of retail fees, which is the fee finding showing up in the default run rather than a separate result.
Read the P&L column as a demonstration of the machinery, not as a strategy comparison. Two things still make it uninformative:
- Synthetic data is not a market. Prices come from a GBM with a fixed spread and no informed flow, so adverse selection, the thing that actually decides whether market making pays, is absent by construction. The 2.8 bps reported here is symmetric price diffusion, not toxicity.
- The Sharpe figures are not meaningful. They annualise a per-tick P&L difference over a one-hour window, so the denominator is tick noise and the values swing across orders of magnitude between strategies.
Inventory and execution statistics from the same run are meaningful, because they are properties of the quoting rule and the fill engine rather than of the price process. That is what the inventory experiment measures, and why it is stated as the headline result while P&L is not.
An earlier version of this table reported a 0.3–0.6% fill rate and 13 to 22
fills. That was a defect in the demo configuration, not a property of the
strategies: the synthetic tape spread 10,000 trades across seven days, roughly
one a minute, while resting orders expire after two seconds. Every quote was
already dead before the next trade could reach it. The tape now spans one hour by
default, giving a gap near 360 ms, and the fill rate is 39%. --synthetic-minutes
controls it, and a test asserts the gap stays inside the order lifetime.
What the run establishes is that the pipeline is sound end to end: quotes are posted and cancelled, the FIFO engine gates on latency and queue position, inventory limits and the kill switch bind, fees land on every fill, and the accounting reconciles. Those are the properties the 136 tests assert.
An earlier version of this section reported figures from a live Binance download. They were removed rather than updated for two reasons: the endpoint is no longer reachable from every network, so nobody could reproduce them, and they predate a units error in the V2 reservation price that had the inventory-skewed strategies quoting roughly 14% away from the market. Any number produced before that fix describes a strategy that was not doing what it claimed.
A meaningful comparison needs real trade data over a long enough window to produce thousands of fills. That is the honest next step for this project.
Plots saved to output/:
{strategy}_pnl_curve.png— cumulative PnL + drawdown{strategy}_inventory.png— inventory over time + distribution{strategy}_quotes.png— market price, posted quotes, fill markersstrategy_comparison.png— side-by-side metric comparisonlatency_sensitivity.png— PnL / fill rate / Sharpe vs latency (if--latency-study)
usage: backtester.py [--synthetic] [--symbol SYMBOL] [--start YYYY-MM-DD] [--end YYYY-MM-DD]
[--strategy {v1,v2,v3}] [--compare-all]
[--half-spread-bps N] [--base-size N] [--max-inventory N] [--gamma N]
[--latency-ms N] [--queue-fraction N]
[--max-drawdown N] [--max-position-usd N]
[--output-dir DIR] [--no-plots] [--latency-study]
Data:
--synthetic Use synthetic GBM data (no network required)
--symbol Trading pair, e.g. BTCUSDT (default: BTCUSDT)
--start / --end Date range YYYY-MM-DD
Strategy:
--strategy v1 | v2 | v3 (default: v3)
--compare-all Run all three and compare
--half-spread-bps Half-spread in basis points (default: 5.0)
--base-size Base order size in BTC (default: 0.001)
--max-inventory Max inventory in BTC before kill switch (default: 0.05)
--gamma Risk aversion for inventory skew (default: 0.1)
Simulator:
--latency-ms Network + exchange latency in ms (default: 50)
--queue-fraction Assumed queue position [0=front, 1=back] (default: 0.5)
--fee-tier retail | volume_tier | rebate | zero (default: retail)
--maker-bps Override maker fee in bps; negative means a rebate
--taker-bps Override taker fee in bps
Risk:
--max-drawdown Max drawdown in USD before kill switch (default: 500)
--max-position-usd Max position value in USD (default: 5000)
Posts a symmetric bid/ask spread around mid price at a fixed half-spread with constant size. No signal conditioning, no inventory control.
bid = mid × (1 - half_spread_bps / 10000)
ask = mid × (1 + half_spread_bps / 10000)
size = base_size (constant)
Adapts the Avellaneda-Stoikov (2008) framework with a heuristic reservation price:
skew = inventory / max_inventory # ∈ [-1, +1]
reservation_mid = mid - γ × skew × σ² × mid
half_spread = base_half_spread × (1 + |skew|) # widens near limits
bid_size = base_size × max(0.1, 1 - max(0, skew))
ask_size = base_size × max(0.1, 1 + min(0, skew))
When long, both quotes shift bearishly (encouraging sells). When short, both quotes shift bullishly (encouraging buys).
Extends V2 with real-time signal conditioning:
momentum_signal = tanh(trade_flow / normalizer) # ∈ [-1, +1]
imbalance_signal = order_book_imbalance # ∈ [-1, +1]
combined_signal = α × momentum + (1-α) × imbalance
quote_offset = β × combined_signal × half_spread
# Toxic flow protection
if trade_intensity > threshold:
half_spread *= spread_widening_factor
Positive combined signal (buy pressure) raises both bid and ask, letting the strategy lean into momentum. The spread widens during high-intensity periods to reduce adverse selection from informed flow.
| Metric | Description |
|---|---|
| Sharpe | Annualized (realized + unrealized) PnL return / volatility |
| Fill Rate | Fraction of submitted orders that received at least one fill |
| Adverse Selection | Average price move after fill in the unfavorable direction (bps) |
| Average Holding Time | Mean time between matched bid/ask fills (ms) |
| Inventory Autocorrelation | Persistence of inventory imbalance; high value = poor mean-reversion |
| Max Drawdown | Peak-to-trough drop in total PnL (USD) |
| Fees Paid | Cumulative exchange fees, negative when the venue pays a net rebate |
For a market maker the fee schedule is not a rounding correction, it is the economics. A strategy quoting a 5 bps half-spread captures about 10 bps on a completed round trip. At the retail tier that round trip pays roughly 20 bps in fees, so the fee is larger than the entire gross edge and reverses it. Any market-making P&L reported gross of fees describes a venue that does not exist.
The simulator therefore charges fees on every fill, separating maker from taker liquidity, and defaults to the retail tier rather than to zero so the unflattering case is the one you get by accident. Compare tiers with:
python backtester.py --synthetic --strategy v3 --fee-tier retail| Tier | Maker | Taker | Who trades here |
|---|---|---|---|
retail (default) |
10 bps | 10 bps | Standard account on a major centralised exchange |
volume_tier |
2 bps | 4 bps | Small proprietary desk at moderate volume |
rebate |
−2.5 bps | 7.5 bps | Venues paying makers to post liquidity |
zero |
0 | 0 | Isolating strategy logic in tests only |
Override either side directly with --maker-bps / --taker-bps. The practical
reading is that passive market making needs either a rebate venue or a genuine
volume tier; at retail fees the spread has to be several times wider than
anything competitive before the strategy clears its own costs.
The data layer uses Binance US public REST API (no API key required):
data/downloader.py— downloadsaggTradeswith pagination and cachingdata/reconstructor.py— reconstructs synthetic best bid/ask from rolling price change distributiondata/loader.py— synthetic GBM data for offline/testing use
Note: historical L2 order book data is not available via Binance US public REST. Best bid/ask is reconstructed from the rolling distribution of price changes (5th percentile of |ΔP| as half-spread proxy). For more accurate simulation, replace with L2 data from a commercial provider.
pytest # all tests
pytest tests/test_fill_engine.py -v # fill engine only
pytest -k "v3 or inventory" # filter by name
pytest --no-cov # skip coverageTest coverage breakdown:
simulator/fill_engine.py— 96% (17 edge cases)data/reconstructor.py— 100%risk/engine.py— 89%strategy/v{1,2,3}— 96–98%- Integration tests covering full pipeline + CLI smoke test
trader/
├── pyproject.toml # dependencies, build config, pytest/coverage settings
├── backtester.py # CLI entry point
│
├── data/
│ ├── downloader.py # Binance US REST client with rate limiting and caching
│ ├── loader.py # load cached data or generate synthetic trades
│ ├── reconstructor.py # MarketSnapshot reconstruction from raw trades
│ └── features.py # microprice, imbalance, realized vol, signed flow
│
├── simulator/
│ ├── order.py # Order, Fill, QuoteRequest dataclasses
│ ├── fill_engine.py # FIFO fill logic, latency, anti-cross, expiry
│ └── event_loop.py # tick-by-tick simulation loop
│
├── strategy/
│ ├── base.py # abstract BaseStrategy interface
│ ├── v1_naive.py # fixed spread baseline
│ ├── v2_inventory.py # inventory-aware reservation price
│ └── v3_orderflow.py # order flow + toxic flow protection
│
├── risk/
│ └── engine.py # FIFO PnL, inventory, drawdown, kill switch
│
├── analytics/
│ ├── metrics.py # Sharpe, fill rate, adverse selection, regime analysis
│ └── plots.py # matplotlib visualization
│
└── tests/
├── conftest.py # synthetic data fixtures
├── test_features.py
├── test_fill_engine.py # 17 fill engine edge cases
├── test_strategies.py
├── test_risk_engine.py
├── test_metrics.py
└── test_integration.py # end-to-end pipeline + CLI tests
- Fill model: simplified FIFO without true queue depth. Real fills depend on order size relative to the actual queue at each price level.
- Order book reconstruction: synthetic bid/ask from trade data. L2 data would enable more accurate imbalance and microprice signals.
- Single exchange: no cross-venue arbitrage or hedging.
- Fees are a flat per-tier rate: real schedules step with rolling volume, and funding costs on perpetuals are not modeled.
Potential extensions:
- Cross-exchange arbitrage simulator
- Reinforcement learning strategy (V4)
- WebSocket-based live paper trading mode
MIT, see LICENSE. Resume bullets for this project are in docs/resume_bullets.md.

