Skip to content

Repository files navigation

Market-making simulator

A market-making simulator studying inventory skewing and adverse selection, with a theory-vs-search test of the Avellaneda–Stoikov optimal-quoting formula.

Open in Streamlit

▶ Try the live dashboard — runs the real engine in the browser, no install.

What it does

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.

Run it

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

Experiments

  • E1 — skew study (e1_skew_study.*): grid over the skew coefficient k_inv at 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-winning k_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 puts k* 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 gives gamma* = 7.8e-4, an implied time-average k_bar = 0.00365inside the plateau (ratio 0.81 to k*), so the theory's level lands where search lands, and lands slightly below k* 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.

Dashboard

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.

Two modelling choices worth knowing

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).

Notes and limits

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.

About

A market-making simulator studying inventory skewing and adverse selection, with a theory-vs-search test of the Avellaneda-Stoikov optimal-quoting formula.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages