A market-making simulator studying inventory skewing and adverse selection, with a theory-vs-search test of the Avellaneda–Stoikov optimal-quoting formula.
▶ Try the live dashboard — runs the real engine in the browser, no install.
Inventory management on synthetic prices. A single market maker quotes both sides of a
random-walk mid for one 6.5h session (23,400 one-second steps), absorbing a mix of noise
and informed flow. Everything decomposes into total_pnl = spread_pnl + inventory_pnl
— what the quote promised, and what the position cost while the mid moved.
Four experiments (E1–E4) sweep the inventory-skew coefficient, the informed-flow share and
the spread width over 200 simulated sessions each, then ask whether the closed-form
Avellaneda–Stoikov skew reproduces the optimum that brute-force search finds. Headline
result: skewing lifts Sharpe from 0.00 to 1.37, adverse selection is paid for out of
inventory losses rather than reduced spread capture, and A–S gets the level of the skew
right while losing on its shape. Every CSV and PNG in results/ regenerates from source
in about a minute.
A live Streamlit dashboard ships with it — streamlit run dashboard.py — driving the
same engine interactively, with sliders for every parameter and one-session paths plotted
against the precomputed 200-session curves.
pip install -r requirements.txt
python3 sim.py # engine self-test
python3 run_experiments.py # E1-E3, ~25s -> results/
python3 run_e4_theory_vs_search.py # E4, ~35s -> results/
streamlit run dashboard.py # live dashboard
- E1 — skew study (
e1_skew_study.*): grid over the skew coefficientk_invat phi=0.20. Skewing barely dents spread capture but collapses inventory risk: P&L std falls $68 → $4 and peak inventory 44 → 6 units, lifting Sharpe from 0.00 to 1.37. Extreme skew (k=2e-2) overpays — quotes cross the mid and spread capture dies to $0.01. - E2 — adverse-selection dial (
e2_adverse_selection.*): grid over informed share phi at the E1-winningk_inv. Mean P&L falls ~linearly ($10.6 − 22.9·phi), going negative near phi≈0.47. The finding to verify holds: spread capture is flat to within $0.03 across the whole dial while inventory P&L drops $11.42 — informed flow is paid for out of inventory losses, not out of reduced spread capture. - E3 — spread-width frontier (
e3_spread_frontier.*): grid over a half-spread multiplier. Interior optimum at 2.5× ($13.31), with 2.5× and 3× statistically tied. Too tight (0.5×) loses money outright; too wide (6×) is priced out at 123 fills.
Run python3 run_e4_theory_vs_search.py (~35s) for the follow-up study:
- E4 — theory vs search (
e4_*): does the Avellaneda–Stoikov closed-form skew match the empirical optimum? A 50-point fine sweep putsk*at 0.00449 (Sharpe 1.37) inside a 2.4×-wide plateau, k ∈ [0.0024, 0.0058]. Implementing A–S's own time-varying skew (-gamma·sigma²·(T−t)·q) and sweeping gamma givesgamma* = 7.8e-4, an implied time-averagek_bar = 0.00365— inside the plateau (ratio 0.81 tok*), so the theory's level lands where search lands, and lands slightly belowk*as the phi=0.20 informed flow predicts it should. Its shape loses: Sharpe 1.02 vs 1.37, and 1.17 vs 1.44 on a held-out 200 sessions. The A–S schedule lets inventory ramp 1.06 → 3.37 units across the day; that late inventory is free of price risk but not of adverse selection, which A–S does not model.
Live: https://marketmaking-defbglenei2lk6hah2hbon.streamlit.app/
pip install -r requirements.txt
streamlit run dashboard.py
dashboard.py drives the real engine — it imports sim.simulate_session, it does not
reimplement anything. Sliders for phi, k_inv, half_spread, sigma, lambda and drift, plus
a session seed and a Reroll button.
Two sections, and the split between them is the point. Section 1 runs one session at
the current settings (~4 ms) and shows the price path with the quote band, inventory, and
the running P&L decomposition — the feel of a configuration. Section 2 drops a marker
onto the precomputed 200-session curves from results/ — the averaged truth. Nothing in
section 2 is recomputed on a slider move: 200 sessions per interaction would either lag or
tempt a session count small enough to lie.
The single session is labelled everywhere as one draw. It is worth rerolling the seed a few
times to see why: at the defaults (k_inv=0) two adjacent seeds give −$63.85 and +$9.57
with not a single slider touched. Section 2 also flags when your other sliders have moved
the book off a curve's configuration — a curve run at phi=0.2 says nothing about a book
running phi=0.5.
Quote skew is invisible at session scale (a $0.02 band against a mid that wanders dollars), so the price panel carries an inset plotting the quotes relative to the mid, which subtracts the random walk and leaves the skew: when inventory is long the ask crosses below the mid and we pay to flatten.
simulate_session grew an optional record_path=False parameter for this — when True it
also returns a per-second trajectory (mid, bid, ask, inventory, cumulative spread and total
P&L). The default path is untouched: all six experiment CSVs regenerate byte-identically,
and python3 sim.py asserts the recorded path reconciles to the summary exactly.
Fills depend on distance from the mid, not on half_spread alone. A trader accepts if
our quote's distance from the mid is inside their exponential reservation threshold. Skew
moves that distance asymmetrically — long inventory shrinks the ask edge (sell more) and
grows the bid edge (buy less). This is what makes skewing work at all; if fills keyed off
half_spread alone, k_inv would change prices but not flow.
Arrivals are pooled, not pre-assigned to a side. Informed traders pick the side their
information favours. Pre-assigning sides would make half of them walk away, so trade count
would fall as phi rose and E2 would confound adverse selection with a volume effect — in a
pilot, spread capture dropped $13.65 → $10.27 across the dial purely from thinning flow.
Pooling holds arrival intensity fixed so phi changes only the correlation between the
side traded and the next price move. Total intensity is still 2·lambda (lambda per side).
Sessions use common random numbers, so configs see identical price paths and arrival
times — config-to-config comparisons are far tighter than the raw session std suggests.
Trades are one unit; close-out is at the mid, so Tier 1 charges no liquidation cost.
E3 extends the brief's {0.5, 1, 1.5, 2, 3} grid with 2.5×/4×/6×: at the default
threshold mean (0.02, twice the base half-spread) the optimum sits past 3×, so the
original grid would have reported a grid-edge maximum rather than an interior one.
No limit order book, no queue position, no real data — that is Tier 2.