Build a Multi-Process Order Book Engine
Build a price-time priority matching engine using Python concurrency primitives.
Introduction
30 Second Summary
Every time you buy a stock online, your order travels through a matching engine that pairs buyers and sellers in microseconds. The architecture behind these engines is one of the most common system design questions in quantitative finance interviews.
In this project, you will build a multi-process order book matching engine in Python. Your system will combine processes, threads, and asyncio to handle concurrent orders, match them by price-time priority, and stream trades to a live analytics consumer.
What You'll Build
You run the system and watch orders flow from an async gateway across process boundaries into a matching engine that fills them by price-time priority, then streams trades to an analytics process printing volume-weighted statistics in real time.
By the end of this project, you'll have:
- Submit limit and market orders and watch them match in real time across separate processes, with trade prints showing price, quantity, and sub-millisecond execution timing.
- See trades stream to a live analytics consumer that computes and prints rolling VWAP and bid-ask spread as the order book changes.
- Inspect how custom decorators transparently validate orders, measure function latency in microseconds, and wrap both sync and async functions without boilerplate.
- Secret Mission: Add a risk manager process that uses a bidirectional Pipe to reject orders when position limits are breached, demonstrating back-pressure across process boundaries.
Are there any prerequisites?
You should be comfortable with basic Python: classes, functions, and simple data structures like lists and dictionaries. No prior experience with concurrency, multiprocessing, or financial systems is required.
Before We Start
Before we dive in, let's lock in what you're about to build and why it matters.
In a system design interview, you need to explain your architecture in one clear sentence before touching a whiteboard. This step is your chance to commit that sentence to memory.
Set Up the Project
Before writing any matching logic, you need a clean project structure with the right dependencies isolated from your system Python.
In this step, you'll create the project directory, spin up a virtual environment, install the one external library the engine depends on, and scaffold the five files that make up the system.
In this step, get ready to:
- Create the project directory on your Desktop.
- Set up a virtual environment and install sortedcontainers.
- Create the five Python files that make up the system.
Create the project directory
- Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type Visual Studio Code and press Enter to open it.
- Open the integrated terminal by clicking Terminal in the top menu bar.
- Click New Terminal.
- Move to your Desktop by running this command:
cd ~/Desktop
- Create the project folder and move into it by running these commands:
mkdir orderbook-engine && cd orderbook-engine
Set up the virtual environment and install dependencies
A virtual environment isolates your project's packages from your system Python. This prevents version conflicts between projects.
- Create and activate the virtual environment by running these commands:
macOS
python3 -m venv venv
source venv/bin/activate
Windows
python -m venv venv
venv\Scripts\activate
You should see (venv) appear at the beginning of your terminal prompt. This confirms the environment is active.
Don't see (venv) in your prompt?
On Windows, you may need to allow script execution first. Run Set-ExecutionPolicy -Scope CurrentUser -ExecutionPolicy RemoteSigned in PowerShell, then try the activate command again.
On macOS, confirm you ran source venv/bin/activate (not just venv/bin/activate without source).
Still stuck? Help me activate my Python virtual environment in VS Code's terminal.
- Install the sortedcontainers library by running:
pip install sortedcontainers==2.4.0
You should see output ending with Successfully installed sortedcontainers-2.4.0.
Why sortedcontainers?
The matching engine needs to find the best bid and best ask price instantly. SortedDict from sortedcontainers keeps price levels in sorted order automatically, giving you O(log n) insertion and O(1) access to the best price.
Create the project files and verify
The system is split across five files, each responsible for one part of the architecture.
- Create the five project files by running this command:
macOS
touch models.py engine.py gateway.py analytics.py main.py
Windows
New-Item models.py, engine.py, gateway.py, analytics.py, main.py
- Confirm the files exist by running:
ls
You should see analytics.py, engine.py, gateway.py, main.py, and models.py in the output (along with the venv directory).
What is each file for?
- models.py holds the Order and Trade dataclasses.
- engine.py holds the matching engine logic.
- gateway.py holds the async order gateway.
- analytics.py holds the analytics consumer process.
- main.py is the process orchestrator that ties everything together.
Before you run the final check, what do you expect to see if sortedcontainers installed correctly?
- Verify everything is working by running:
python -c "from sortedcontainers import SortedDict; print('ready')"
You should see ready printed to the terminal. This confirms Python can import the library inside your virtual environment.
Seeing ModuleNotFoundError?
Make sure your virtual environment is still active. You should see (venv) in your prompt. If it disappeared, re-run the activate command from earlier.
If the environment is active but the import still fails, re-run pip install sortedcontainers==2.4.0 and check for error messages in the output.
Still stuck? Help me fix a ModuleNotFoundError for sortedcontainers in my virtual environment.
- Open the orderbook-engine folder in VS Code by clicking File in the top menu bar.
- Click Open Folder.
- Navigate to your Desktop, select the orderbook-engine folder, and click Open.
You should see all five .py files listed in the Explorer sidebar on the left.
Your environment is ready. Next up, you'll build the matching engine core and watch it execute trades.
Build the Matching Engine Core
The matching engine is the heart of any trading system. It takes incoming orders and decides which ones to fill against each other based on price-time priority: the best price always wins, and within the same price, the earliest order gets filled first.
Before adding process boundaries and IPC, you need to see the matching algorithm work in isolation. In this step you will build the data models and the engine in a single process, then run it to watch a market order sweep resting limit orders off the book.
In this step, get ready to:
- Define immutable Order and Trade dataclasses with frozen slots.
- Implement a matching engine using SortedDict and deque for price-time priority.
- Run the engine standalone to verify market orders sweep the book correctly.
Define data models in models.py
Every order and trade in the system needs a fixed, predictable shape. Frozen dataclasses with slots give you immutability (orders cannot be accidentally mutated after creation) and lower memory overhead per instance.
- Open models.py in VS Code and add the imports, enums, and Order dataclass by pasting this code:
from dataclasses import dataclass
from decimal import Decimal
from enum import Enum
import time
class Side(Enum):
BUY = "BUY"
SELL = "SELL"
class OrderType(Enum):
LIMIT = "LIMIT"
MARKET = "MARKET"
@dataclass(frozen=True, slots=True)
class Order:
order_id: int
symbol: str
side: Side
order_type: OrderType
price: Decimal
quantity: int
timestamp: float
What does this code do?
Side and OrderType are enums that restrict order attributes to known values. This prevents bugs from typos like "buy" vs "BUY".
The @dataclass(frozen=True, slots=True) decorator on Order does two things. frozen=True makes instances immutable, so an order cannot be changed after creation. slots=True generates __slots__ for lower memory usage per instance.
Prices use Decimal instead of floats because floating point arithmetic produces rounding errors that are unacceptable in financial systems.
- Below the Order class, add the Trade dataclass:
@dataclass(frozen=True, slots=True)
class Trade:
trade_id: int
symbol: str
buy_order_id: int
sell_order_id: int
price: Decimal
quantity: int
timestamp: float
What does this code do?
Trade records every fill that the matching engine produces. It links back to both the buy and sell order IDs so you can trace exactly which orders matched. Like Order, it is frozen and slotted.
- Below the Trade class, add the create_order helper function:
def create_order(order_id: int, symbol: str, side: Side, order_type: OrderType,
price: float, quantity: int) -> Order:
return Order(
order_id=order_id,
symbol=symbol,
side=side,
order_type=order_type,
price=Decimal(str(price)),
quantity=quantity,
timestamp=time.time(),
)
Why a factory function?
create_order converts a plain float price into a Decimal via Decimal(str(price)) and stamps the current time automatically. This keeps the calling code clean and avoids repeating conversion logic everywhere.
- Save models.py.
- Verify the models load correctly by running this in your terminal:
python -c "from models import Order, Trade, Side, OrderType, create_order; print(create_order(1, 'AAPL', Side.BUY, OrderType.LIMIT, 150.0, 100))"
You should see an Order object printed with all fields populated, including a Decimal price of 150.0 and a timestamp.
Seeing a ModuleNotFoundError?
Make sure your virtual environment is activated (you should see (venv) in your terminal prompt). Also confirm you are running the command from inside the orderbook-engine folder.
help me debug a ModuleNotFoundError when importing my models
Implement the MatchingEngine class
The engine uses a SortedDict for each side of the book. Bids store negated price keys so the highest bid is always at index 0. Asks store natural price keys so the lowest ask is always at index 0. Each price level holds a deque of orders for FIFO matching within that level.
- Open engine.py and add the imports and timing decorator:
import time
import functools
from collections import deque
from decimal import Decimal
from sortedcontainers import SortedDict
from models import Order, Trade, Side, OrderType, create_order
def timing(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
start = time.perf_counter_ns()
result = func(*args, **kwargs)
elapsed_us = (time.perf_counter_ns() - start) / 1000
print(f"[TIMING] {func.__name__} took {elapsed_us:.2f} us")
return result
return wrapper
What does the timing decorator do?
The @timing decorator measures how long a function takes in microseconds using time.perf_counter_ns(). It wraps any function transparently thanks to functools.wraps. You will apply it to add_order() so every order submission prints its latency.
- Below the decorator, add the MatchingEngine class definition with its constructor and routing methods:
class MatchingEngine:
def __init__(self):
# Bids: negated keys so highest bid is at index 0
self.bids: SortedDict = SortedDict()
# Asks: natural keys so lowest ask is at index 0
self.asks: SortedDict = SortedDict()
self.trade_counter: int = 0
self.trades: list[Trade] = []
def _get_book_side(self, side: Side) -> SortedDict:
return self.bids if side == Side.BUY else self.asks
def _get_opposing_side(self, side: Side) -> SortedDict:
return self.asks if side == Side.BUY else self.bids
@timing
def add_order(self, order: Order) -> list[Trade]:
if order.order_type == OrderType.MARKET:
return self._match_market_order(order)
else:
return self._match_limit_order(order)
Why negate bid prices?
SortedDict maintains keys in ascending order. By negating bid prices, the highest bid (best bid) always sits at keys()[0]. Asks naturally sort lowest-first, so the best ask is also at keys()[0]. This means fetching the best price on either side is always an O(1) index-zero lookup.
- Save engine.py and verify the file parses by running:
python -c "from engine import MatchingEngine; e = MatchingEngine(); print('Engine initialized')"
You should see Engine initialized printed. This confirms your imports, decorator, and class definition are syntactically correct.
- Back in engine.py, add the limit and market order handlers below add_order:
def _match_limit_order(self, order: Order) -> list[Trade]:
trades = self._try_match(order)
remaining_qty = order.quantity - sum(t.quantity for t in trades)
if remaining_qty > 0:
# Place unfilled portion on the book
resting_order = Order(
order_id=order.order_id,
symbol=order.symbol,
side=order.side,
order_type=order.order_type,
price=order.price,
quantity=remaining_qty,
timestamp=order.timestamp,
)
self._insert_order(resting_order)
return trades
def _match_market_order(self, order: Order) -> list[Trade]:
return self._try_match(order)
What does this code do?
_match_limit_order first attempts to match against the opposing side. Whatever quantity remains unfilled gets placed on the book as a resting order. Market orders skip this step since they never rest on the book.
- Now add the core _try_match method. This is the price-time priority algorithm. Add the first part, which walks the opposing book to find the best price:
def _try_match(self, order: Order) -> list[Trade]:
trades: list[Trade] = []
opposing = self._get_opposing_side(order.side)
remaining_qty = order.quantity
while remaining_qty > 0 and len(opposing) > 0:
# Find best opposing price
if order.side == Side.BUY:
best_price_key = opposing.keys()[0]
best_price = best_price_key
else:
best_price_key = opposing.keys()[-1]
best_price = -best_price_key
# Check if limit order can cross
if order.order_type == OrderType.LIMIT:
if order.side == Side.BUY and best_price > order.price:
break
if order.side == Side.SELL and best_price < order.price:
break
What does this code do?
The outer while loop keeps matching as long as there is remaining quantity and the opposing book is not empty. For a buy order, the best ask is at keys()[0] (lowest ask). For a sell order, the best bid is at keys()[-1] of the bids dict (highest bid, since bids use negated keys).
The limit price check ensures a limit buy does not pay more than its price, and a limit sell does not accept less. If the opposing price is worse, the loop breaks and the remainder rests on the book.
- Still inside _try_match, continue the method with the inner fill loop that creates Trade objects. Add this code directly below the limit price check (still inside the outer while loop):
# Fill against resting orders at this price level
level_queue: deque = opposing[best_price_key]
while remaining_qty > 0 and len(level_queue) > 0:
resting_order = level_queue[0]
fill_qty = min(remaining_qty, resting_order.quantity)
self.trade_counter += 1
if order.side == Side.BUY:
buy_id = order.order_id
sell_id = resting_order.order_id
else:
buy_id = resting_order.order_id
sell_id = order.order_id
trade = Trade(
trade_id=self.trade_counter,
symbol=order.symbol,
buy_order_id=buy_id,
sell_order_id=sell_id,
price=Decimal(str(abs(best_price))),
quantity=fill_qty,
timestamp=time.time(),
)
trades.append(trade)
self.trades.append(trade)
remaining_qty -= fill_qty
What does this code do?
Each price level is a deque (FIFO queue). The inner loop fills against the front of the queue first (earliest order at that price). fill_qty is the minimum of remaining quantity and the resting order's quantity, producing partial fills when sizes do not match exactly.
- Complete the _try_match method by adding the partial fill handling and level cleanup. Add this directly below remaining_qty -= fill_qty (still inside the inner while loop):
if fill_qty == resting_order.quantity:
level_queue.popleft()
else:
partial = Order(
order_id=resting_order.order_id,
symbol=resting_order.symbol,
side=resting_order.side,
order_type=resting_order.order_type,
price=resting_order.price,
quantity=resting_order.quantity - fill_qty,
timestamp=resting_order.timestamp,
)
level_queue[0] = partial
if len(level_queue) == 0:
del opposing[best_price_key]
return trades
What does this code do?
If a resting order is fully filled, it is removed from the queue with popleft() (O(1) on a deque). If partially filled, a new immutable Order with the reduced quantity replaces it at the front. Once a price level is empty, it is deleted from the SortedDict so the next iteration picks up the next-best price.
- Save engine.py and confirm the file still imports cleanly:
python -c "from engine import MatchingEngine; print('Matching methods loaded')"
You should see Matching methods loaded. If you see a syntax error, double-check your indentation. All methods must be indented under the class body.
- Add the _insert_order method and the book query helpers below _try_match:
def _insert_order(self, order: Order) -> None:
book = self._get_book_side(order.side)
if order.side == Side.BUY:
key = -order.price
else:
key = order.price
if key not in book:
book[key] = deque()
book[key].append(order)
def get_best_bid(self) -> Decimal | None:
if len(self.bids) == 0:
return None
return -self.bids.keys()[0]
def get_best_ask(self) -> Decimal | None:
if len(self.asks) == 0:
return None
return self.asks.keys()[0]
What does this code do?
_insert_order places resting orders into the correct SortedDict using negated keys for bids, natural keys for asks. Insertion into a SortedDict is O(log P) where P is the number of active price levels.
get_best_bid and get_best_ask return the top-of-book price on each side. Since SortedDict keeps keys sorted, this is an O(1) index lookup.
- Add the print_book method below get_best_ask:
def print_book(self) -> None:
print("\n--- Order Book ---")
print("ASKS (lowest first):")
for price_key in self.asks:
level = self.asks[price_key]
total_qty = sum(o.quantity for o in level)
print(f" {price_key}: {total_qty} ({len(level)} orders)")
print("BIDS (highest first):")
for price_key in self.bids:
level = self.bids[price_key]
total_qty = sum(o.quantity for o in level)
print(f" {-price_key}: {total_qty} ({len(level)} orders)")
print(f"Spread: {self.get_best_bid()} / {self.get_best_ask()}")
print("------------------\n")
- Save engine.py.
Test the engine with limit and market orders
The engine class is complete. Now you need a test harness that submits orders and lets you see the matching in action.
- At the bottom of engine.py, add this if __name__ == "__main__" block:
if __name__ == "__main__":
engine = MatchingEngine()
# Submit 5 limit orders to build the book
orders = [
create_order(1, "AAPL", Side.BUY, OrderType.LIMIT, 150.00, 100),
create_order(2, "AAPL", Side.BUY, OrderType.LIMIT, 149.50, 200),
create_order(3, "AAPL", Side.SELL, OrderType.LIMIT, 151.00, 150),
create_order(4, "AAPL", Side.SELL, OrderType.LIMIT, 151.50, 100),
create_order(5, "AAPL", Side.BUY, OrderType.LIMIT, 150.50, 50),
]
for order in orders:
trades = engine.add_order(order)
if trades:
for t in trades:
print(f"TRADE: {t.quantity}@{t.price}")
engine.print_book()
- Below engine.print_book(), still inside the if __name__ block, add the market order test:
print("--- Sending market sell order for 250 shares ---")
market_order = create_order(6, "AAPL", Side.SELL, OrderType.MARKET, 0, 250)
trades = engine.add_order(market_order)
for t in trades:
print(f"TRADE: {t.quantity}@{t.price}")
engine.print_book()
What does this test do?
First, five limit orders build the book: three bids and two asks. None cross each other, so no trades happen yet. Then a market sell for 250 shares sweeps the bid side. It should fill against the best bid first (150.50 for 50 shares), then the next level (150.00 for 100 shares), then partially fill the 149.50 level (100 of its 200 shares).
- Save engine.py.
Before you run it, what do you expect to see? The 5 limit orders should produce zero trades (they do not cross). The market sell should produce 3 trade prints as it sweeps through bid levels.
- Run the engine by executing:
python engine.py
You should see timing output for each add_order call, then the book state showing asks at 151.00 and 151.50, bids at 150.50, 150.00, and 149.50. After the market sell, you should see three TRADE prints sweeping from 150.50 down through 149.50, with the book's bid side partially consumed.
Seeing a NameError or AttributeError?
Double check that every method inside MatchingEngine is indented with exactly 4 spaces under the class definition. Also verify that the if __name__ block is at the module level (zero indentation), not inside the class.
help me debug my matching engine NameError or AttributeError
✔️ Awesome, I've got everything!
Great. Double check that you have saved both models.py and engine.py.
ⓧ I'd like to double check the full code
Your models.py should contain the imports, Side and OrderType enums, Order dataclass, Trade dataclass, and create_order function. Scroll up and confirm each block is present in order.
Your engine.py should contain these sections in order:
- Imports and the timing decorator.
- The MatchingEngine class with __init__, _get_book_side, _get_opposing_side, and add_order.
- _match_limit_order and _match_market_order.
- _try_match with the outer price-selection loop, inner fill loop, partial fill handling, and return.
- _insert_order, get_best_bid, get_best_ask, and print_book.
- The if __name__ == "__main__" block with 5 limit orders and 1 market sell.
You now have a working matching engine that you can explain in complexity terms: add is O(log P), cancel is O(1) with an index, and matching is O(F * log P) where F is the number of fills. Next up, you will wrap this engine with an async gateway that validates and queues orders concurrently.
Add the Async Order Gateway
Your matching engine works, but it only processes one order at a time from a simple list. In a real exchange, hundreds of orders arrive concurrently from different clients.
In this step, you'll build an async order gateway using asyncio that validates and queues multiple orders without blocking. You'll also use decorators to keep validation logic separate from business logic.
In this step, get ready to:
- Create validation and timing decorators that enforce order integrity.
- Build an async OrderGateway class that buffers and processes orders concurrently.
- Run the gateway standalone and observe how matching still happens sequentially.
Create the validation and timing decorators
Before building the gateway class, you need decorator functions that handle validation and performance measurement. Decorators let you wrap these concerns around any async function without polluting the function's core logic.
- Open gateway.py in VS Code and add the imports and the validate_order decorator:
import asyncio
import functools
import time
from models import Order, Side, OrderType, create_order
from engine import MatchingEngine, timing
def validate_order(func):
@functools.wraps(func)
async def wrapper(self, order: Order, *args, **kwargs):
if order.quantity <= 0:
raise ValueError(f"Invalid quantity: {order.quantity}")
if order.order_type == OrderType.LIMIT and order.price <= 0:
raise ValueError(f"Invalid price for limit order: {order.price}")
if not order.symbol:
raise ValueError("Symbol cannot be empty")
return await func(self, order, *args, **kwargs)
return wrapper
What does this decorator do?
The validate_order decorator intercepts every order before it reaches the gateway's submit logic. It checks three conditions: positive quantity, positive price for limit orders, and a non-empty symbol.
If any check fails, it raises a ValueError immediately. The actual submit function never executes for invalid orders. functools.wraps preserves the original function's name and docstring so debugging stays clean.
- Below the validate_order function, add the async_timing decorator:
def async_timing(func):
@functools.wraps(func)
async def wrapper(*args, **kwargs):
start = time.perf_counter_ns()
result = await func(*args, **kwargs)
elapsed_us = (time.perf_counter_ns() - start) / 1000
print(f"[TIMING] {func.__name__} took {elapsed_us:.2f} us")
return result
return wrapper
Why a separate async_timing decorator?
The timing decorator in engine.py wraps synchronous functions. Since the gateway uses async methods, you need a version that awaits the wrapped function. time.perf_counter_ns() gives nanosecond precision, converted to microseconds for readability.
- Save gateway.py.
- Verify the decorators load correctly by running:
python -c "from gateway import validate_order, async_timing; print('decorators loaded')"
You should see decorators loaded printed with no errors.
Seeing an ImportError?
- Make sure you are running the command from inside your orderbook-engine folder where models.py and engine.py live.
- Check that your virtual environment is active (you should see (venv) in your terminal prompt).
- Confirm the spelling of from engine import MatchingEngine, timing matches your engine.py exactly.
Help me debug this ImportError in gateway.py
Build the OrderGateway class
The gateway class uses an asyncio.Queue to buffer incoming orders and processes them one at a time from the queue. This pattern separates order acceptance (fast, async) from order execution (slower, synchronous matching).
- Below the async_timing decorator in gateway.py, add the OrderGateway class:
class OrderGateway:
def __init__(self, engine: MatchingEngine):
self.engine = engine
self.order_queue: asyncio.Queue = asyncio.Queue()
self.processed_count: int = 0
@validate_order
@async_timing
async def submit_order(self, order: Order) -> None:
await self.order_queue.put(order)
async def process_orders(self) -> None:
while True:
order = await self.order_queue.get()
if order is None:
break
trades = self.engine.add_order(order)
self.processed_count += 1
if trades:
for t in trades:
print(f" [GATEWAY] Trade: {t.quantity}@{t.price}")
async def run(self, orders: list[Order]) -> None:
processor = asyncio.create_task(self.process_orders())
submit_tasks = [self.submit_order(order) for order in orders]
await asyncio.gather(*submit_tasks)
await self.order_queue.put(None)
await processor
print(f"[GATEWAY] Processed {self.processed_count} orders")
What does this class do?
- submit_order() is stacked with both decorators. First @validate_order rejects bad orders, then @async_timing measures how long the submit takes.
- process_orders() is a consumer loop. It pulls orders from the async queue and feeds them to the matching engine until it receives a None sentinel (the shutdown signal).
- run() orchestrates everything. It spawns the consumer as a background task, then uses asyncio.gather() to submit all orders concurrently.
Why asyncio.gather instead of a loop?
A for loop with await would submit orders one at a time, waiting for each to finish before starting the next. asyncio.gather() fires all submit coroutines at once. In a real system, this simulates hundreds of clients sending orders simultaneously over WebSocket connections.
- Below the class, add the if __name__ == "__main__" block that creates 10 test orders and runs the gateway:
if __name__ == "__main__":
engine = MatchingEngine()
gateway = OrderGateway(engine)
orders = [
create_order(i, "AAPL", Side.BUY if i % 2 == 0 else Side.SELL,
OrderType.LIMIT, 150.0 + (i * 0.1), 10 + i)
for i in range(1, 11)
]
asyncio.run(gateway.run(orders))
engine.print_book()
What does the test harness do?
The list comprehension creates 10 orders with alternating BUY/SELL sides and slightly increasing prices. This guarantees some orders will cross (a buy at 150.2 crosses a sell at 150.1) so you see trades printed. asyncio.run() boots up the event loop, runs the gateway, and shuts down cleanly.
- Save gateway.py.
Seeing a SyntaxError?
- Check that async def submit_order and async def process_orders both have the async keyword before def.
- Make sure the if __name__ block is at the top level (no indentation), not inside the class.
Help me fix a SyntaxError in my gateway.py
Run the gateway and observe sequential matching
The gateway is complete. Before you run it, think about what you expect to see in the timing output.
Do you think all 10 orders will be matched concurrently, or will matching still happen one order at a time? Why?
- Run the gateway by executing:
python gateway.py
You should see [TIMING] lines for each submit_order call showing sub-millisecond validation times. You'll also see [TIMING] output from add_order as the engine matches orders, trade prints, and the final order book state.
What should I notice about the timing?
Look at the [TIMING] add_order lines. Even though you submitted all 10 orders via asyncio.gather(), the matching happens sequentially. Each add_order call finishes before the next one starts.
This is because the matching engine is synchronous code running inside the same event loop. While one order is being matched, no other coroutine can run. The gateway and engine share a single process, so a slow match blocks new orders from being accepted. This is the limitation you'll solve in the next step with process separation.
✔️ Awesome, I've got everything!
Great. Double check you've saved gateway.py and that your terminal shows trades and the order book state.
ⓧ I'd like to double check the full code
Scroll up and confirm your gateway.py contains these sections in order:
- Imports (asyncio, functools, time, models, engine).
- The validate_order decorator function.
- The async_timing decorator function.
- The OrderGateway class with submit_order, process_orders, and run methods.
- The if __name__ == "__main__" block creating 10 orders and calling asyncio.run().
You now have a working async gateway that validates and queues orders concurrently. But the single-process bottleneck is clear. In the next step, you'll separate the gateway and engine into different processes so that a slow match never blocks order acceptance.
Separate into Processes with IPC
Your gateway and engine both work, but they share a single process. That means a slow match blocks order acceptance.
In this step, you will separate the gateway and engine into distinct processes connected by a multiprocessing.Queue. This mirrors how real exchanges isolate latency-sensitive paths. A threaded logger will write trades to disk without blocking the matching loop.
In this step, get ready to:
- Spawn a gateway process and engine process communicating via a multiprocessing Queue.
- Add a daemon thread inside the engine process that logs trades to disk as JSON lines.
- Run the system and observe two different PIDs with trade output and a trades.log file.
Write the engine process with threaded logging
The engine process will live in main.py. It needs a helper function that runs on a background thread, writing each trade to disk as JSON without blocking the matching loop.
Why threads for logging but processes for the engine?
Python's GIL prevents threads from running CPU-bound Python code in parallel. But I/O operations like file writes release the GIL. So a logging thread can write to disk while the matching engine runs without any blocking.
The matching engine is CPU-bound (comparing prices, managing deques). It needs a separate process to run truly in parallel with the gateway. This is the canonical answer for "when do you use threads vs processes in Python?" in interviews.
- Create a new file called main.py in your orderbook-engine folder and paste the imports and trade_logger function:
import asyncio
import json
import multiprocessing
import os
import queue
import threading
import time
from models import Order, Trade, Side, OrderType, create_order
from engine import MatchingEngine
def trade_logger(log_queue: queue.Queue, log_file: str) -> None:
lock = threading.Lock()
with open(log_file, "a") as f:
while True:
trade = log_queue.get()
if trade is None:
break
record = {
"trade_id": trade.trade_id,
"symbol": trade.symbol,
"buy_order_id": trade.buy_order_id,
"sell_order_id": trade.sell_order_id,
"price": str(trade.price),
"quantity": trade.quantity,
"timestamp": trade.timestamp,
}
with lock:
f.write(json.dumps(record) + "\n")
f.flush()
What does this code do?
The imports pull in every concurrency primitive you will use: multiprocessing for process spawning, threading for the logger thread, asyncio for the gateway's event loop, and queue for the thread-safe in-process queue.
The trade_logger function runs in a loop, pulling trades from a queue.Queue and writing each as a JSON line. The threading.Lock ensures file writes are atomic. A None sentinel signals the thread to exit cleanly.
- Below the trade_logger function, add the engine_process function that consumes orders from a multiprocessing.Queue:
def engine_process(order_queue: multiprocessing.Queue,
trade_queue: multiprocessing.Queue) -> None:
print(f"[ENGINE] Process started (PID: {os.getpid()})")
engine = MatchingEngine()
log_queue: queue.Queue = queue.Queue()
logger_thread = threading.Thread(
target=trade_logger, args=(log_queue, "trades.log"), daemon=True
)
logger_thread.start()
while True:
order = order_queue.get()
if order is None:
trade_queue.put(None)
log_queue.put(None)
break
trades = engine.add_order(order)
for trade in trades:
print(f"[ENGINE] Trade #{trade.trade_id}: "
f"{trade.quantity}@{trade.price}")
trade_queue.put(trade)
log_queue.put(trade)
logger_thread.join(timeout=2)
print(f"[ENGINE] Shut down. Total trades: {engine.trade_counter}")
What does this code do?
- os.getpid() prints the process ID so you can prove this runs in a separate process from the gateway.
- The daemon=True flag means the logger thread dies automatically when the engine process exits. No zombie threads.
- The main while True loop blocks on order_queue.get() waiting for orders from the gateway. When it receives None, it forwards that sentinel to the trade queue and log queue, then exits.
- Each trade goes to both trade_queue (for downstream consumers) and log_queue (for the logger thread). The logger writes to disk while the engine continues matching.
- Save main.py.
- Verify the code parses by running this import test:
python -c "from main import engine_process, trade_logger; print('engine ready')"
You should see engine ready printed with no errors.
Seeing an ImportError?
- Make sure your virtual environment is activated. You should see (venv) in your terminal prompt.
- Check that models.py and engine.py are in the same directory as main.py.
Help me debug this import error in my main.py file.
Create the gateway process
The gateway process generates orders using an asyncio event loop and pushes them onto the shared multiprocessing.Queue. It runs in its own process, so slow matching in the engine never blocks order acceptance.
- Below engine_process in main.py, add the gateway_process function:
def gateway_process(order_queue: multiprocessing.Queue) -> None:
print(f"[GATEWAY] Process started (PID: {os.getpid()})")
import random
random.seed(42)
orders = []
for i in range(1, 21):
side = Side.BUY if random.random() < 0.5 else Side.SELL
price = round(150.0 + random.uniform(-2, 2), 2)
qty = random.randint(10, 100)
order = create_order(i, "AAPL", side, OrderType.LIMIT, price, qty)
orders.append(order)
async def submit_all():
for order in orders:
if order.quantity <= 0:
continue
if order.order_type == OrderType.LIMIT and order.price <= 0:
continue
order_queue.put(order)
await asyncio.sleep(0.01)
asyncio.run(submit_all())
order_queue.put(None)
print(f"[GATEWAY] Submitted {len(orders)} orders")
What does this code do?
- The function generates 20 random AAPL limit orders with a fixed seed (so results are reproducible).
- asyncio.run(submit_all()) starts the event loop inside this process. Each order gets a 10ms delay (asyncio.sleep(0.01)) to simulate real-world order arrival spacing.
- After all orders are submitted, a None sentinel tells the engine process to shut down.
- order_queue.put(order) is the IPC boundary. The order crosses from the gateway process into the engine process through a serialized Queue.
Wire up the orchestrator and run the system
The orchestrator is the if __name__ == "__main__" block that creates the Queues, spawns both processes, and waits for them to finish. On macOS, this guard is required because the default start method is spawn, meaning child processes re-import the module.
- At the bottom of main.py, add the orchestrator block:
if __name__ == "__main__":
order_queue: multiprocessing.Queue = multiprocessing.Queue()
trade_queue: multiprocessing.Queue = multiprocessing.Queue()
print(f"[MAIN] Orchestrator (PID: {os.getpid()})")
engine_proc = multiprocessing.Process(
target=engine_process, args=(order_queue, trade_queue)
)
gateway_proc = multiprocessing.Process(
target=gateway_process, args=(order_queue,)
)
engine_proc.start()
gateway_proc.start()
gateway_proc.join()
engine_proc.join(timeout=10)
print("[MAIN] All processes complete.")
What does this code do?
- Two multiprocessing.Queue objects form the IPC channels. order_queue carries orders from gateway to engine. trade_queue carries trades out of the engine (used by the analytics process in the next step).
- multiprocessing.Process(target=...) spawns a child process that runs the given function. Each process gets its own memory space and its own PID.
- .join() blocks the main process until the child finishes. The engine gets a 10-second timeout in case something hangs.
- Save main.py.
Before you run this, think about what you expect to see. How many different PID numbers should appear in the output? Will trades print from the gateway process or the engine process?
- Run the multi-process system:
python main.py
You should see three distinct PIDs in the output: one for [MAIN], one for [GATEWAY], and one for [ENGINE]. Trade prints come from the engine process. The final line says [MAIN] All processes complete.
- Confirm the trade logger wrote to disk by running:
cat trades.log
You should see JSON lines, each containing a trade_id, symbol, price, and quantity fields. These were written by the logger thread without blocking the matching engine.
Not seeing any output or the script hangs?
- Make sure the if __name__ == "__main__" guard is present. Without it, macOS's spawn start method re-imports the file and spawns infinite processes.
- Check that gateway_process sends None at the end. Without the sentinel, the engine loop never breaks and the script hangs forever.
- If you see a pickling error, confirm your Order and Trade dataclasses in models.py use only picklable types (Decimal, int, float, str, Enum).
Help me debug why my multi-process order book is hanging or not producing output.
Why use a Queue instead of a Pipe here?
A multiprocessing.Queue is multi-producer, multi-consumer. In a real exchange, multiple gateway processes could feed orders into the same queue. A Pipe is point-to-point and bidirectional, better suited for request/response patterns. You will see Pipe in action during the Secret Mission.
✔️ Awesome, I've got everything!
Great. Double check you have saved main.py.
ⓧ I'd like to double check the full code
Your main.py should contain these five sections from top to bottom. Scroll up and confirm each is present in order:
- Imports block (asyncio, json, multiprocessing, os, queue, threading, time, plus models and engine imports).
- trade_logger function (reads from a queue, writes JSON lines to a file).
- engine_process function (creates engine, starts logger thread, loops on order_queue).
- gateway_process function (generates 20 orders, pushes onto order_queue, sends None sentinel).
- if __name__ == "__main__" block (creates Queues, spawns engine_proc and gateway_proc, joins both).
You now have true process isolation. The gateway and engine run in separate memory spaces with separate PIDs. Next up, you will add an analytics consumer process that computes live VWAP and spread statistics from the trade stream.
Add the Analytics Consumer and Complete the System
You now have orders flowing from the gateway process, through a Queue, into the engine process that matches them and logs trades to disk.
But trade data disappears into a log file with no live analysis. In this step, you'll add a third process that consumes trades from the engine and computes live VWAP (Volume Weighted Average Price) statistics as they arrive.
In this step, get ready to:
- Build an analytics consumer process that computes rolling VWAP from a trade stream.
- Wire a three-process architecture with graceful shutdown via sentinel propagation.
- Run the full pipeline with 50 orders and observe three distinct PIDs cooperating.
Implement the AnalyticsConsumer class
The analytics process needs to read Trade objects from the same multiprocessing.Queue the engine writes to, then accumulate volume-weighted price data.
What is VWAP?
VWAP (Volume Weighted Average Price) is sum(price * quantity) / sum(quantity) across all trades. It tells you the average price paid per share, weighted by how many shares traded at each price.
- Open analytics.py and add the imports and class definition by pasting this code:
import multiprocessing
from decimal import Decimal
from models import Trade
class AnalyticsConsumer:
def __init__(self, trade_queue: multiprocessing.Queue):
self.trade_queue = trade_queue
self.trades: list[Trade] = []
self.total_volume: int = 0
self.total_notional: Decimal = Decimal("0")
def compute_vwap(self) -> Decimal | None:
if self.total_volume == 0:
return None
return self.total_notional / self.total_volume
What does this code do?
- trade_queue is the multiprocessing Queue that the engine process writes Trade objects into.
- total_notional accumulates sum(price * quantity) across all trades using Decimal for exact arithmetic.
- compute_vwap() divides total notional by total volume to produce the volume-weighted average price.
- Below the compute_vwap method, add the run() method and the module-level entry point function:
def run(self) -> None:
import os
print(f"[ANALYTICS] Process started (PID: {os.getpid()})")
while True:
trade = self.trade_queue.get()
if trade is None:
break
self.trades.append(trade)
self.total_volume += trade.quantity
self.total_notional += trade.price * trade.quantity
if len(self.trades) % 5 == 0:
vwap = self.compute_vwap()
print(f"[ANALYTICS] Trades: {len(self.trades)} | "
f"Volume: {self.total_volume} | "
f"VWAP: {vwap:.4f}")
vwap = self.compute_vwap()
print(f"[ANALYTICS] Final | Trades: {len(self.trades)} | "
f"Volume: {self.total_volume} | "
f"VWAP: {vwap:.4f}" if vwap else "[ANALYTICS] No trades.")
def run_analytics(trade_queue: multiprocessing.Queue) -> None:
consumer = AnalyticsConsumer(trade_queue)
consumer.run()
What does this code do?
- The run() loop blocks on trade_queue.get() waiting for the engine to send trades across the process boundary.
- Every 5 trades, it prints a rolling VWAP snapshot so you can watch the average price shift in real time.
- A None sentinel signals the loop to exit, then it prints a final summary.
- run_analytics() is a module-level function that multiprocessing.Process can target directly.
- Save analytics.py.
- Verify the module loads correctly by running this command:
python -c "from analytics import AnalyticsConsumer; print('analytics ready')"
You should see analytics ready printed with no import errors.
Seeing an ImportError?
- Make sure your virtual environment is still active. You should see (venv) in your terminal prompt.
- Check that models.py is in the same directory as analytics.py. The import from models import Trade depends on it.
Why can't Python find my analytics module?
✔️ Awesome, I've got everything!
Great. Double check you have saved analytics.py.
ⓧ I'd like to double check the full code
Your analytics.py should have these sections in order. Scroll up and confirm each is present:
- Imports: multiprocessing, Decimal, and Trade.
- AnalyticsConsumer class with __init__ and compute_vwap().
- run() method with the while-loop, 5-trade print interval, and final summary.
- Module-level run_analytics() function at the bottom.
Wire the analytics process into main.py
The engine already writes trades to trade_queue and sends a None sentinel when it shuts down. You need to spawn an analytics process that reads from that same queue.
- In main.py, find the imports at the top of the file. Find this line:
from engine import MatchingEngine
- Add the analytics import directly below it, so the imports now look like this:
from engine import MatchingEngine
from analytics import run_analytics
- Next, scroll down to the gateway_process function. Find this line inside the for loop:
for i in range(1, 21):
- Change 21 to 51 so the gateway submits 50 orders instead of 20:
for i in range(1, 51):
- Finally, replace the entire if __name__ == "__main__" block at the bottom of main.py with this updated version that spawns all three processes:
if __name__ == "__main__":
order_queue: multiprocessing.Queue = multiprocessing.Queue()
trade_queue: multiprocessing.Queue = multiprocessing.Queue()
print(f"[MAIN] Orchestrator (PID: {os.getpid()})")
engine_proc = multiprocessing.Process(
target=engine_process, args=(order_queue, trade_queue)
)
analytics_proc = multiprocessing.Process(
target=run_analytics, args=(trade_queue,)
)
gateway_proc = multiprocessing.Process(
target=gateway_process, args=(order_queue,)
)
engine_proc.start()
analytics_proc.start()
gateway_proc.start()
gateway_proc.join()
engine_proc.join(timeout=10)
analytics_proc.join(timeout=10)
print("[MAIN] All processes complete.")
What changed in the orchestrator?
- analytics_proc targets run_analytics and receives the same trade_queue the engine writes to. This is fan-out IPC: one producer, two consumers (logger thread + analytics process).
- The shutdown chain is Gateway sends None to the order queue, Engine receives it and forwards None to the trade queue, Analytics receives it and exits.
- Each .join(timeout=10) ensures the main process does not hang forever if a child gets stuck.
Why Queue for fan-out instead of Pipe?
A multiprocessing.Queue is multi-producer, multi-consumer. That means you could add a second analytics consumer later without changing the engine.
A Pipe is point-to-point and bidirectional, which makes it ideal for request/response patterns (like the risk manager in the Secret Mission). Choosing the right IPC primitive based on communication shape is exactly what interviewers ask about.
- Save main.py.
Getting a NameError on run_analytics?
- Confirm you added from analytics import run_analytics at the top of main.py.
- Make sure analytics.py is saved in the same orderbook-engine directory as main.py.
Why is Python unable to import run_analytics?
Run the complete three-process system
Before you run this, think about what you expect to see. How many distinct PIDs should appear, and in what order will the processes print their startup messages?
- If you have a trades.log file from the previous step, delete it first so you start fresh:
rm -f trades.log
- Run the full system by executing:
python main.py
You should see output from four distinct PIDs: the main orchestrator, the gateway, the engine, and the analytics process.
What should I see?
- [MAIN] Orchestrator (PID: ...) prints first.
- [ENGINE], [ANALYTICS], and [GATEWAY] each print their PID on startup.
- [ENGINE] Trade #N lines show trades being matched as orders cross the book.
- [ANALYTICS] Trades: N | Volume: N | VWAP: N.NNNN lines appear every 5 trades, showing the rolling average price.
- A final [ANALYTICS] Final line prints the total trade count, volume, and VWAP before shutdown.
- [MAIN] All processes complete. confirms clean shutdown.
- Confirm the trade log was written by running:
wc -l trades.log
The line count should match the total trades reported by the analytics process in its final summary.
Process hangs and never exits?
- Press Ctrl+C to kill it. The most common cause is the sentinel None not propagating. Check that engine_process calls trade_queue.put(None) when it receives None from the order queue.
- If you see [ANALYTICS] No trades., make sure you are passing trade_queue (not order_queue) to run_analytics in the orchestrator.
Why does my multi-process system hang on exit?
✔️ Awesome, I've got everything!
Great. Confirm both analytics.py and main.py are saved.
ⓧ I'd like to double check the full code
Your main.py should have these elements. Scroll up and confirm each is present:
- Imports include from analytics import run_analytics.
- trade_logger function (unchanged from the previous step).
- engine_process function (unchanged from the previous step).
- gateway_process function with range(1, 51) for 50 orders.
- if __name__ == "__main__" block spawning engine_proc, analytics_proc, and gateway_proc.
You now have a complete multi-process trading system: async order acceptance, process-isolated matching, threaded logging, and a live analytics pipeline. This is exactly the architecture diagram you sketch on a whiteboard during a quant system design interview.
Secret mission
Add a Risk Manager with Back-Pressure via Pipe
Your matching engine accepts every order that passes basic validation. But real exchanges enforce position limits to prevent a single trader from accumulating dangerous exposure. Can you add a fourth process that uses bidirectional Pipe communication to approve or reject orders before they reach the engine?
Clean Up Your Resources
Clean Up Your Resources
This project runs entirely locally with no cloud resources or paid services. There are no ongoing costs to worry about.
Resources you used:
- Virtual environment (venv/ directory inside orderbook-engine) containing the sortedcontainers library.
- trades.log file generated by the trade logger thread.
- The orderbook-engine project folder with all Python source files.
Decide whether to keep your resources, or delete them entirely.
Keep everything running
No action needed. Choose this if you want to keep the project as interview prep material or continue experimenting with the matching engine.
- Your orderbook-engine folder stays on your Desktop with all source files intact.
- The virtual environment stays ready. Reactivate it anytime with source venv/bin/activate (macOS/Linux) or venv\Scripts\activate (Windows).
- The trades.log file will grow each time you run the system. Delete it manually if it gets large.
Pause - I'll come back to this later
Since everything is local, there is nothing to pause. The project uses no running services or background processes between sessions.
- Deactivate the virtual environment by running deactivate in your terminal.
- The project folder stays on your Desktop. Come back anytime by navigating into orderbook-engine and reactivating the virtual environment.
Delete - I don't want to use this again
Remove all project files and the virtual environment completely. This is not reversible.
- Deactivate the virtual environment first (if active) by running deactivate in your terminal.
- Navigate to your Desktop and remove the entire project folder.
On macOS/Linux:
cd ~/Desktop
rm -rf orderbook-engine
On Windows:
cd ~\Desktop
rmdir /s /q orderbook-engine
This removes all Python source files, the virtual environment, and the trades.log file in one step.
Nice Work!
Nice Work!
You just built a multi-process order book matching engine that demonstrates every concurrency primitive Python offers working together with clean IPC boundaries. This is exactly the architecture you whiteboard in a quant system design interview.
Here's what you learned:
- Built a price-time priority matching engine using SortedDict and deque, then separated it into isolated processes communicating via multiprocessing.Queue to achieve true parallelism beyond the GIL.
- Implemented an async order gateway with asyncio.gather for concurrent order submission, and created custom decorators for timing and validation that keep cross-cutting concerns separate from business logic.
- Added a threaded trade logger that writes to disk without blocking the matching engine, and built a real-time analytics consumer process computing VWAP from the trade stream.
- Secret Mission: Added bidirectional Pipe-based communication for a risk manager with back-pressure, rejecting orders once position limits are breached.
Ready to quiz yourself?