Lecture 1: Spread
A practical series for the discerning retail trader and the quantitative alchemist on Market Microstructure
🕯️Greetings, esteemed reader!
What I shall commence with is the study of Market Microstructure. I crafted it to be useful for both the discerning retailer of trade and the adept quantitative alchemist.
I’d classify these series as Lectures.
As a practitioner to the bone, I’ll try to convey only things you can use in the code, but achieving results as a quant requires some theory and mental effort.
Markers
🔬: theory-heavy concepts useful for context, but less directly monetizable.
🛠️: hands-on code, heuristics, or tips.
Required Knowledge
Basic order‑book vocabulary and high-school math.
If the pendulum of your interest has swung into deeper research, you can find articles and book references at the end.
Outcome
A working knowledge toolkit to measure spread.
Markets: CLOBs, RFQ, AMMs, hybrids.
Liquidity
Bid–Ask Spread: Quoted, Normalized, Effective, Realized.
Order Book Depth and Slippage
🔥 Shall we commence?
Let’s start with fundamental definitions and basic yet practical models, laying the groundwork for everything subsequent.
I treat Market Microstructure as the area of study of how orders are placed, matched, and cancelled, and how that process shapes prices, liquidity, volatility, and trading costs. This semi-formal definition pretty much reflects the concept of a bridge between the raw bid/ask queue and the asset price.
Market Designs
CLOBs
Continuous limit‑order books run by most of the venues we trade on: Coinbase, Binance, Kraken, Bybit, Hyperliquid, NASDAQ, NYSE, LSE, TSE, and pretty much most of them.
This is often taken for granted, but it is essential to note that CLOB is not the only market type out there.
AMMs
On-chain DEXes, these implementations are considerably different: constant product, concentrated liquidity, balancer, hybrid pools, - hopefully I’ll be persistent enough with this blog to share what I know for all of them as I am using all of the versions of Uniswap (v2,v3, and v4) and Solana DEXes.
RFQ
RFQs markets worth mentioning: one party asks for a quote; counterparty responds with a price - as simple as that. That’s the case for onchain protocols like 0x RFQ, Paradigm, and DeFi aggregators. On the centralized side, that’s the structure of OTC desks like Binance’s and Wintermute. Bloomberg is an example from the TradFi space.
OTC
Сall/batch auctions is the last market type I’d like to mention since it’s a popular token fair launch idea - bids and asks are being collected, then at once they are matched against each other. In TradFi, that’s how earlier NYSE and LSE operated; they matched orders once a day. Since CEXes offer APIs for call auctions, that might move one’s thoughts in the right direction! I might create a post on that later.
For the sake of clarity, there are other market designs (dealer markets, batch auctions, RFQs, dark pools, prediction markets, and a variety of hybrids). The list depends on one’s fantasy, since more market designs appear like Order Flow Auctions with MEV redistribution, and obviously, you can design your own market. We’ll stick to CLOBs and AMMs for now.
Four Dimensions of Liquidity
So market liquidity, or just liquidity (when I’ll be talking about funding liquidity or monetary liquidity, I’ll say that explicitly), is, semi-formally, the ability to facilitate an asset's quick trading without significantly impacting its price.
In a more structured way, modern theory defines the following liquidity dimensions:
tightness: cost of executing a small trade,
🛠️ we’ll measure that by spread.depth: volume near the current price that can be traded without moving it,
🛠️ order book sizing & slippage curves;immediacy: order execution speed,
🛠️ VWAP lag, time component of the Implementation Shortfall;resiliency: how quickly prices revert and liquidity replenishes after shocks,
🛠️ Realized spread, book recovery time.
Why care? Because execution costs often dwarf model alpha, especially in HFT.
We choose markets to trade on, design execution algos, and size orders. Bad execution ruins an excellent strategy.
Spread Measures
Quoted Spread
Ok, so you, of course, know what a spread is - the best ask less than the best bid:
That’s the quoted spread S at time t:
That absolute value doesn’t give much: a 10¢ spread is typical for APPL but crazy for CRV, that’s why normalizing quoted spread with the midprice m sounds reasonable, providing the relative quoted spread s:
Spread, being the most cited measure of market <il>liquidity, actually does work very well. It’s a valid tx cost model for a tiny round-trip transaction that's executed at the best bid and ask, which also assumes immediate (zero-lag!) order book feeds and execution.
🛠️ Extremely handy for realtime lowlatency monitoring: tight normalized spread ⇒ high liquidity
I know, “immediate execution” and “immediate feeds” sound like a “spherical horse in a vacuum”, but quoted normalized spread is actually a good ultrarobust liquidity estimator.
Weighted Average Spread
Weighted average bid-ask spread for an order size q: Assuming that a and b are average execution prices of buy and sell orders, respectively, then the weighted-average bid-ask spread is
It's pretty intuitive: the market is not deep enough if, with the growth of q, the wa-spread s is increasing significantly as well. When q is small, it’s close to the quoted spread; for larger q, it reflects depth and slippage.
Estimating these weighted average best bids/asks is a real way to estimate slippage (more on slippage is coming) and model the liquidity surface (for large orders).
Subscribe to the depth feed and compute:
Choose a notional q you plan to trade.
Walk the book’s asks from the best price upward until you accumulate q to get a(q). Same for bids downward to get b(q).
Compute midprice and plug into the above formula.
In the end of this lecture you’ll find the code to compute that.
Effective Spread
Requires much less data - just the last execution price, p, showing the transaction's impact on the market.
d is the trade direction 1 for longs/buys, -1 for shorts/sells
Realized Spread
Now we’re getting close to business. Quoted and Effective spreads are more measures for a trader, realized spread is more interesting for market makers, as a MM you want to be as neutral as possible, and RS measures the extra cost (or profit) sustained by a MM relative to an ideal environment in which trades are made at the midprice. It assumes we keep the assets for Δ periods, then a realized spread
Thus, the average RS, given the above effective spread definition,
Order Book Depth and Slippage
🛠️ Capacity to absorb large orders. Sizing, scaling, and choosing venues to trade are one of the most complex tasks in algotrading.
Depth captures the cumulative quantity available for execution at, and away from, the best bid and ask, while slippage denotes the adverse price movement a participant experiences when their order consumes that liquidity.
In further lectures, I’m going to walk through commonly used aggregates—top‑of‑book depth, X‑basis‑point depth, and depth‑implied dollar value, as well as decay models that describe replenishment rates. We then derive instantaneous and execution-weighted slippage measures.
🔬 Literature on microstructure offers ways to estimate the direction of trade d: classic Lee-Ready algorithm and Odders-White. It doesn’t make much sense since all cryptocurrency exchanges now provide aggressiveness flags to indicate whether an order was initiated by the seller or buyer, and we always have quotes.
In the literature on microstructure, there is a lot of information on Roll’s measure (1984), which is an estimator of the spread derived from the price time series. The problem with it is that it doesn’t work in crypto - momentum crashes the idea. It uses the negative autocovariance of successive price changes. It has lots of assumptions, and in trending markets or momentum, when there’s positive autocorrelation, it gives zero or wrong estimates. If you'd like to read more about it, I leave references below.
I’ve outlined how it works in my post on liquidity measures, since it’s very neat and helps a lot to grasp the concept of mm modelling. Check it out.
Thanks for reading!
Now, let’s get straight to the Liquidity Measures and code some money‑making spells! 💸🪄
References and Reading List
Kyle, A. S. (1985). Continuous auctions and insider trading. *Econometrica, 53*(6), 1315‑1335.
Lee, C. M. C., & Ready, M. J. (1991). Inferring trade direction from intraday data. *Journal of Finance, 46*(2), 733‑746.
O’Hara, M. (1995). *Market Microstructure Theory*. Blackwell.
Roll, R. (1984). A simple implicit measure of the effective bid‑ask spread in an efficient market. *Journal of Finance, 39*(4), 1127‑1139.
Harris, L. (2003). Trading and Exchanges: Market Microstructure for Practitioners. Oxford University Press.
Foucault, Pagano, Röell. Market Liquidity: Theory, Evidence, and Policy (2013), Oxford University Press.
Obizhaeva, A., & Wang, J. (2013). Optimal trading strategy and supply/demand dynamics. *Journal of Financial Markets, 16*(1), 1‑32.
Weighted Average Spread Code
Here I’m sharing a simple but effective code snippet that does two things:
Generates a realistic random order‑book snapshot (bids + asks) with tick‑aligned prices and size that decays deeper in the book.
Computes the weighted‑average bid‑ask spread for any trade size q as defined above.
Python, requires pandas
and numpy
import pandas as pd
import numpy as np
###############################################################################
# 1. ORDER‑BOOK GENERATOR
###############################################################################
def random_orderbook(
mid: float = 100.0, # central price around which we build the book
tick: float = 0.01, # price granularity
spread_ticks: int = 2, # best‑bid/ask gap in ticks
depth_levels: int = 20, # levels per side
base_size: float = 1_000.0, # expected size at the best bid/ask
depth_decay: float = 0.15, # how quickly size grows deeper in book
sigma_vol: float = 0.5, # randomness in size (log‑normal std‑dev)
rng: np.random.Generator | None = None
) -> pd.DataFrame:
"""
Build a one‑shot synthetic order book with realistic features:
• tick‑aligned prices, symmetric around 'mid'
• quoted spread = spread_ticks * tick
• depth increases (on average) as we move away from the top
• log‑normal noise to avoid perfectly smooth shapes
"""
rng = rng or np.random.default_rng()
half_spread = (spread_ticks * tick) / 2
# --- price ladders -------------------------------------------------------
ask_px = mid + half_spread + tick * np.arange(depth_levels)
bid_px = mid - half_spread - tick * np.arange(depth_levels)
# --- sizes: grow with depth + randomness ---------------------------------
vol_multiplier = rng.lognormal(mean=0.0, sigma=sigma_vol, size=depth_levels)
depth_factor = np.exp(depth_decay * np.arange(depth_levels))
ask_sz = base_size * depth_factor * vol_multiplier
bid_sz = base_size * depth_factor * vol_multiplier # symmetric book
asks = pd.DataFrame({"side": "ask", "price": ask_px, "size": ask_sz})
bids = pd.DataFrame({"side": "bid", "price": bid_px, "size": bid_sz})
return pd.concat([bids, asks], ignore_index=True)
###############################################################################
# 2. EXECUTION‑PRICE ROUTINE
###############################################################################
def _avg_exec_price(book: pd.DataFrame, side: str, qty: float) -> float:
"""
Walk the book and compute the volume‑weighted average execution
price for either a buy ('ask' side) or sell ('bid' side) order of size 'qty'.
"""
side_book = book.query("side == @side").copy()
# For buys we start at BEST ASK (lowest); for sells at BEST BID (highest)
side_book = side_book.sort_values(
"price", ascending=(side == "ask") # True→asks low→high ; False→bids high→low
).reset_index(drop=True)
cum = side_book["size"].cumsum()
if qty > cum.iat[-1]:
raise ValueError("Requested quantity exceeds book depth.")
take_full = side_book.loc[cum < qty, ["price", "size"]]
take_part = side_book.loc[cum >= qty].iloc[0]
filled_qty = take_full["size"].sum()
remaining = qty - filled_qty
vwap_numer = (take_full["price"] * take_full["size"]).sum()
vwap_numer += take_part["price"] * remaining
return vwap_numer / qty
###############################################################################
# 3. WEIGHTED‑AVERAGE SPREAD FOR ANY SIZE q
###############################################################################
def wa_spread(book: pd.DataFrame, q: float) -> float:
"""
Weighted‑average bid‑ask spread *in absolute price units*.
Divide by the mid‑price if you prefer it in relative terms.
"""
a_q = _avg_exec_price(book, "ask", q) # cost to BUY q
b_q = _avg_exec_price(book, "bid", q) # proceeds to SELL q
mid = (book.query("side == 'ask'")["price"].min() +
book.query("side == 'bid'")["price"].max()) / 2
return (a_q - b_q) / mid # relative form (dimensionless)
###############################################################################
# 4. QUICK DEMO (comment out when importing!)
###############################################################################
if __name__ == "__main__":
book = random_orderbook()
for q in [1_000, 5_000, 15_000]:
print(f"q={q:>6}: wa‑spread = {wa_spread(book, q):.4%}")
Here’s the same for Rustaceans
src/lib.rs
use rand::prelude::*;
use rand_distr::{Distribution, LogNormal};
/// One price/size point on a side of the book.
#[derive(Clone, Copy)]
pub struct Level {
pub price: f64,
pub size: f64,
}
/// Sides we can walk.
#[derive(Clone, Copy)]
pub enum Side { Bid, Ask }
/// A synthetic order‑book snapshot.
pub struct OrderBook {
bids: Vec<Level>, // sorted high → low
asks: Vec<Level>, // sorted low → high
}
impl OrderBook {
// ------------------------------------------------------------------------
/// Create a random, *symmetric* order book around `mid`.
pub fn random(
mid: f64,
tick: f64,
spread_ticks: usize,
depth_levels: usize,
base_size: f64,
depth_decay: f64,
sigma_vol: f64,
rng: &mut impl Rng,
) -> Self {
let half_spread = (spread_ticks as f64 * tick) / 2.0;
let lognorm = LogNormal::new(0.0, sigma_vol).unwrap();
// Pre‑allocate to avoid re‑allocations
let mut bids = Vec::with_capacity(depth_levels);
let mut asks = Vec::with_capacity(depth_levels);
for lvl in 0..depth_levels {
let depth_fac = (depth_decay * lvl as f64).exp();
let noise = lognorm.sample(rng);
let size = base_size * depth_fac * noise;
// Bid ladder: highest price first
let bid_price = mid - half_spread - tick * lvl as f64;
bids.push(Level { price: bid_price, size });
// Ask ladder: lowest price first
let ask_price = mid + half_spread + tick * lvl as f64;
asks.push(Level { price: ask_price, size });
}
Self { bids, asks }
}
// ------------------------------------------------------------------------
/// Internal helper: average execution price for buying/selling `qty`.
fn avg_exec_price(&self, side: Side, qty: f64) -> Option<f64> {
let book = match side { Side::Bid => &self.bids, Side::Ask => &self.asks };
let mut remaining = qty;
let mut vwap_num = 0.0;
for lvl in book {
if remaining <= 0.0 { break; }
let take = remaining.min(lvl.size);
vwap_num += lvl.price * take;
remaining -= take;
}
if remaining > 1e-9 {
None // depth exhausted
} else {
Some(vwap_num / qty)
}
}
// ------------------------------------------------------------------------
/// Relative weighted‑average spread for order size `q`.
pub fn wa_spread(&self, q: f64) -> Option<f64> {
let a_q = self.avg_exec_price(Side::Ask, q)?;
let b_q = self.avg_exec_price(Side::Bid, q)?;
let best_ask = self.asks.first()?.price;
let best_bid = self.bids.first()?.price;
let mid = 0.5 * (best_ask + best_bid);
Some((a_q - b_q) / mid) // dimensionless
}
}
A quick demo
src/main.rs
use rand::SeedableRng;
use rand::rngs::StdRng;
use orderbook_spread::OrderBook;
fn main() {
let mut rng = StdRng::seed_from_u64(42);
let book = OrderBook::random(
100.0, // mid
0.01, // tick
2, // spread in ticks
20, // depth levels
1_000.0, // size at top of book
0.15, // depth‑decay
0.5, // log‑normal sigma
&mut rng,
);
for q in [1_000.0, 5_000.0, 15_000.0] {
match book.wa_spread(q) {
Some(s) => println!("q = {:>6.0}: wa‑spread = {:.4}%", q, 100.0 * s),
None => println!("q = {:>6.0}: not enough depth", q),
}
}
}
Cargo.toml
[package]
name = "orderbook_spread"
version = "0.1.0"
edition = "2021"
[dependencies]
rand = "0.8"
rand_distr = "0.4"
Run and enjoy!