Market making with Python and public data: quotes, inventory, and what happens after a trade
Suppose you quote both sides of a stock. Someone sells to your bid, so you buy below the current midpoint.
Have you made money?
That depends on what happens next. The price can fall after the trade, your inventory can accumulate, and costs can outweigh a small initial advantage.
The example has two steps. First, measure actual quotes and executions. Then try hypothetical quoting rules with a stated fill assumption. The first step describes historical data. The second depends on the assumptions we add to the simulation.
Start with data readers can download
The archive provides a message CSV and an order-book CSV. The accompanying README explains their columns. These files contain historical Nasdaq data. They use the prices recorded in the sample, rather than today’s split-adjusted Apple prices.
Why use an order book instead of daily closing prices? We need to see the bid and ask separately, distinguish executions from other events, and preserve the sequence in which information arrives.
python -m pip install numpy pandas, then run python market_making_lab.py.data/lobster/ yourself. You can select another folder with --data-dir.It creates a local SQLite database, a table of execution measurements, and a table comparing quote experiments. No brokerage account or trading API is involved.
Understand the rows before calculating anything
These are the two input files:
AAPL_2012-06-21_34200000_57600000_message_1.csv
AAPL_2012-06-21_34200000_57600000_orderbook_1.csvThe message file contains time, event type, order ID, size, price, and direction. The book file contains the best ask price and size, followed by the best bid price and size.
direction = 1 describes a resting buy order being executed. The participant with the resting order buys. The incoming seller sells to that order. A direction of -1 describes the passive sale. These conventions come from LOBSTER’s output specification.The introductory loading calculation is:
from pathlib import Path
import numpy as np
import pandas as pd
folder = Path("data/lobster")
stem = "AAPL_2012-06-21_34200000_57600000"
messages = pd.read_csv(
folder / f"{stem}_message_1.csv", header=None,
names=["time", "event", "order_id", "size", "price", "direction"],
)
book = pd.read_csv(
folder / f"{stem}_orderbook_1.csv", header=None,
names=["ask", "ask_size", "bid", "bid_size"],
)
data = pd.concat([messages, book], axis=1)
data[["price", "bid", "ask"]] /= 10_000
print(len(data))
print(data.loc[0, ["bid", "ask"]].to_list())
# 118497
# [585.33, 585.94]LOBSTER records dollar prices multiplied by 10,000. Dividing by 100 would produce the wrong scale. The complete script also checks row counts, chronological ordering, valid prices, and the absence of halt messages in this sample.
Measure the quoted market
The midpoint, spread, and displayed size imbalance are:
Here, and are the displayed quantities at the best bid and ask. The imbalance describes that displayed state. It does not tell us who will trade next.
data["mid"] = (data["bid"] + data["ask"]) / 2
data["spread"] = data["ask"] - data["bid"]
data["imbalance"] = (
(data["bid_size"] - data["ask_size"])
/ (data["bid_size"] + data["ask_size"])
)
print(f"Median event-snapshot spread: ${data['spread'].median():.2f}")
# Median event-snapshot spread: $0.15Each recorded event contributes one spread observation. Busy periods generate more events, so they contribute more observations to this median. A time-weighted statistic instead weights each quote by how long it remains in effect.
The first spread is $0.61, while the median across the sample is $0.15. A single opening snapshot is therefore a poor description of the entire day.

What happens after an actual execution?
Take a displayed execution at price . Let for a passive purchase and for a passive sale.
For a purchase, a later midpoint above the execution price gives a positive value. For a sale, a later midpoint below the execution price gives a positive value.
We can split the measurement into two pieces:
The first term measures the execution price’s advantage relative to the midpoint before the event. The second measures the later midpoint move, with its sign adjusted for a purchase or sale.
The script selects event type 4, meaning a displayed execution, and excludes executions without a full remaining one-second horizon. At the target time, it uses the last recorded midpoint at or before that time. It does not use the next observation after the target.
Across the 23,646 eligible executions, weighting each measurement by executed shares gives:
| Measurement | Dollars per share |
|---|---|
| Initial edge | +0.049562 |
| Subsequent one-second movement | −0.046832 |
| One-second markout | +0.002730 |
The accounting reconciles: .

The later midpoint move removes most of the initial advantage in this historical sample. If we subtract an illustrative cost of $0.003 per share, the measured average becomes slightly negative. We assumed that cost for illustration. We do not know the actual fee paid by these participants.
A markout values a position at a later midpoint. It is not necessarily the price at which that position could be closed. It does not measure the market maker’s complete profit. The market maker may have hedged, held other positions, or traded at other venues.
Build a small database you can query
market-making-output/market_making.sqlite with two tables:| Table | Contents |
|---|---|
book_events | Original event sequence, prices, sizes, midpoint, spread, and imbalance |
passive_markouts | Eligible displayed executions and their initial edge, subsequent move, and markout |
The script stores a sequence number to preserve event order, even when several events have the same timestamp. Time alone is not a unique row identifier.
Once you have run the script, query the database:
import sqlite3
with sqlite3.connect("market-making-output/market_making.sqlite") as connection:
result = connection.execute("""
SELECT COUNT(*), ROUND(SUM(size * markout) / SUM(size), 6)
FROM passive_markouts
""").fetchone()
print(result)
# (23646, 0.00273)This gives you a reproducible sample database built from the provider’s online files. You can now ask whether markouts differ by direction, spread, or displayed imbalance without repeatedly reading the original CSVs.
Such comparisons are descriptions of this day. Before testing on later data, fix the measurement horizon and grouping rules. Then check whether a feature that looked useful on this day remains useful.
Try a quote rule that responds to inventory
Now use the policy from Part 2:
We compare with dollars per share. Both start from a two-cent half-width, so the nominal full spread is four cents. The code rounds bids down and asks up to an assumed one-cent price grid. It then limits bids and asks so they do not cross the observed opposite quote.
For an isolated example with a $100 midpoint and 10 shares of inventory:
mid, inventory = 100.0, 10
skew, half_width = 0.001, 0.02
centre = mid - skew * inventory
bid = np.floor((centre - half_width) * 100 + 1e-8) / 100
ask = np.ceil((centre + half_width) * 100 - 1e-8) / 100
print(f"Bid: {bid:.2f}, ask: {ask:.2f}")
# Bid: 99.97, ask: 100.01How do we decide whether a hypothetical order fills?
Suppose a displayed execution sells into a resting bid. If its price reaches or falls below our bid, we mark a candidate purchase. If an execution buys from a resting ask, its price must reach or exceed our ask to create a candidate sale.
We then use a seeded random draw with an assumed probability to choose a one-share fill. The code uses seed 42, an inventory limit of 100 shares in either direction, and the illustrative $0.003 fee per executed share. It makes decisions from the previous book row.
At an assumed 50% fill probability, the script produces:
| Result | No inventory shift | Inventory-dependent shift |
|---|---|---|
| Hypothetical one-share fills | 9,415 | 8,898 |
| Maximum absolute inventory | 100 | 62 |
| Ending inventory | 99 | 6 |
| Net terminal marked P&L | +$266.120 | −$83.694 |

Terminal marked P&L is cash plus inventory valued at the final midpoint, after the assumed fees. It does not include an actual closing trade.
The inventory-dependent rule carries less inventory in this run. It also earns a lower marked profit. Reducing inventory risk does not guarantee higher realised profit on one price path.
The script also runs assumed fill probabilities of 25% and 100%. Both policies use the same seeded random draw for each event. Their quotes and inventories differ, so their candidate fills can differ too. Examine how the results change before drawing any conclusion about a policy.
The observed markouts earlier are measurements of real executions. The quote table is a hypothetical experiment. For a realistic strategy test, improve the execution model. Model when orders arrive and when cancellations take effect. Include suitable queue information and evaluate the strategy on later days.
What should you change next?
Start by changing the markout horizon from one second to five seconds. Does the initial edge still survive? Examine purchases and sales separately. Then group observations by spread or imbalance. Report the share count and event count for each group.
In the quote experiment, change the half-width, inventory shift, assumed fees, or fill probability one at a time. Explain how each change affects candidate trades and the positions you carry.
By the end of this lab, explain each number in four steps. State what you observed, what you assumed, which position the trades created, and which price you used to value it.
Until then, peace.
Go deeper with lessons, derivations and worked exercises behind this article.