
Market Microstructure Traditional
- 210 installs
- 257 repo stars
- Updated June 24, 2026
- agiprolabs/claude-trading-skills
market-microstructure-traditional is a Claude Code skill that applies limit-order-book microstructure theory to crypto, covering spreads, price formation, and execution quality.
About
market-microstructure-traditional applies orderbook (LOB) microstructure theory to crypto, covering spread decomposition, price-formation models, execution-quality measurement, and CEX-vs-DEX structural differences. A developer uses it to reason about market making, adverse selection, and execution quality on centralized exchanges. It includes Glosten-Milgrom and Kyle's lambda models.
- Applies traditional LOB microstructure theory to crypto: spread decomposition, price formation, execution quality
- Ships market_maker_sim.py and spread_analysis.py plus price-formation and CEX-vs-DEX references
- Covers Glosten-Milgrom, Kyle's lambda, and effective/realized spread
Market Microstructure Traditional by the numbers
- 210 all-time installs (skills.sh)
- Ranked #451 of 1,106 Finance & Trading skills by installs in the Skillselion catalog
- Data as of Aug 4, 2026 (Skillselion catalog sync)
market-microstructure-traditional capabilities & compatibility
- Capabilities
- market microstructure · execution quality · market making
- Use cases
- data analysis · research · trading
What market-microstructure-traditional says it does
Market microstructure studies how orders become trades and how trades become prices.
Lambda (λ)** measures permanent price impact per unit of signed volume.
npx skills add https://github.com/agiprolabs/claude-trading-skills --skill market-microstructure-traditionalAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 210 |
|---|---|
| repo stars | ★ 257 |
| Last updated | June 24, 2026 |
| Repository | agiprolabs/claude-trading-skills ↗ |
What it does
Apply orderbook microstructure theory to crypto execution quality and market making.
Who is it for?
Reasoning about spreads, adverse selection, and execution quality on orderbook (CEX) markets.
Skip if: AMM/DEX swap-tape orderflow (use market-microstructure).
When should I use this skill?
You need orderbook microstructure theory for market making or execution-quality analysis.
By the numbers
- 3-component spread decomposition
- Glosten-Milgrom and Kyle models covered
- 6 core concepts tabulated
Files
Market Microstructure (Traditional)
Market microstructure studies how orders become trades and how trades become prices. Understanding these mechanics is essential for execution optimization, market making, and detecting informed flow. This skill covers limit order book (LOB) theory as applied to crypto markets on centralized exchanges, and compares LOB mechanics to the AMM-based structure of DEXes.
Core Concepts
| Concept | What It Tells You |
|---|---|
| Bid-ask spread | Cost of immediacy — how much you pay to trade now vs later |
| Price impact | How your order moves the market price |
| Order book imbalance | Short-term directional predictor from queue sizes |
| Adverse selection | Risk of trading against informed counterparties |
| Inventory risk | Market maker exposure from accumulated positions |
| Execution quality | How well your fills compare to a benchmark |
---
Bid-Ask Spread Decomposition
The bid-ask spread is not a single thing. It decomposes into three components (Roll, 1984; Glosten & Harris, 1988):
1. Adverse selection — compensation for trading against informed traders 2. Inventory holding — compensation for carrying risk 3. Order processing — fixed costs of providing liquidity (fees, infrastructure)
Spread Measures
# Quoted spread: what you see on the order book
quoted_spread = best_ask - best_bid
quoted_spread_bps = (best_ask - best_bid) / midprice * 10_000
# Effective spread: what you actually pay (accounts for price improvement)
effective_half_spread = abs(trade_price - midprice_at_trade)
effective_spread_bps = effective_half_spread / midprice_at_trade * 10_000
# Realized spread: market maker's actual profit (after price moves)
# Measured at trade_price vs midprice N seconds later
realized_spread = trade_sign * (trade_price - midprice_after_delay)The effective spread matters most for execution quality. The difference between effective and realized spread measures adverse selection — what the market maker loses to informed flow.
---
Price Formation Models
Glosten-Milgrom (1985)
A sequential trade model where the market maker sets bid and ask prices to break even against a mix of informed and uninformed traders.
- Market maker quotes reflect expected value conditional on trade direction
- Spread exists purely due to adverse selection
- Prices converge to true value as information is revealed through trades
Key insight: the spread is wider when:
- Probability of informed trading (PIN) is higher
- Information asymmetry is larger
- Uninformed trading volume is lower
Kyle's Lambda (1985)
Kyle models a single informed trader, noise traders, and a market maker. The market maker sets price as a linear function of net order flow:
price_change = lambda * net_order_flowLambda (λ) measures permanent price impact per unit of signed volume. Higher lambda = less liquid market. Lambda is estimated by regressing price changes on signed volume:
import numpy as np
from numpy.linalg import lstsq
def estimate_kyle_lambda(
price_changes: np.ndarray,
signed_volumes: np.ndarray,
) -> float:
"""Estimate Kyle's lambda from trade data.
Args:
price_changes: Midprice changes between trades.
signed_volumes: Trade volume * trade_sign (+1 buy, -1 sell).
Returns:
Estimated lambda (price impact per unit volume).
"""
X = signed_volumes.reshape(-1, 1)
beta, _, _, _ = lstsq(X, price_changes, rcond=None)
return float(beta[0])See references/price_formation.md for full model derivations and the PIN model for measuring informed trading probability.
---
Price Impact Models
Temporary vs Permanent Impact (Almgren-Chriss)
When executing a large order:
- Temporary impact — price displacement that reverts after your order.
Caused by consuming standing liquidity.
- Permanent impact — information content of your trade that moves the
equilibrium price. Does not revert.
total_impact = permanent_impact + temporary_impact
permanent = gamma * (shares / ADV)
temporary = eta * (shares / time_horizon) ^ alphaTypical alpha values: 0.5-0.7 (square root impact is a robust empirical finding).
Square Root Impact Law
Empirically, price impact scales as the square root of order size relative to daily volume:
def square_root_impact(
order_size: float,
daily_volume: float,
volatility: float,
impact_coefficient: float = 0.1,
) -> float:
"""Estimate price impact using the square root model.
Args:
order_size: Number of units to trade.
daily_volume: Average daily volume.
volatility: Daily return volatility (decimal).
impact_coefficient: Empirical constant (typically 0.05-0.20).
Returns:
Expected price impact as a fraction.
"""
return impact_coefficient * volatility * (order_size / daily_volume) ** 0.5---
Order Book Imbalance
The ratio of bid-side to ask-side depth near the top of the book predicts short-term price direction:
def order_book_imbalance(
bid_qty: float,
ask_qty: float,
) -> float:
"""Compute order book imbalance.
Returns:
Imbalance in [-1, 1]. Positive = more bids (bullish).
"""
total = bid_qty + ask_qty
if total == 0:
return 0.0
return (bid_qty - ask_qty) / totalImbalance at levels 1-5 is a strong short-term predictor (Cont et al., 2014). Deeper levels add predictive power but decay quickly.
---
Trade Arrival Processes
Poisson Process
Simplest model: trades arrive at a constant rate λ. Inter-arrival times are exponentially distributed. Useful as a baseline but too simple for real order flow.
Hawkes Process
Self-exciting point process where each trade increases the probability of subsequent trades. Captures clustering in order flow:
intensity(t) = mu + sum(alpha * exp(-beta * (t - t_i)))- mu: baseline arrival rate
- alpha: excitation magnitude (how much each event boosts intensity)
- beta: decay rate (how fast excitation fades)
- alpha/beta < 1: stationarity condition (branching ratio)
The branching ratio α/β measures the fraction of trades that are reactions rather than innovations. Typical values: 0.5-0.8 in crypto markets (high reactivity).
---
Market Maker Economics
A market maker profits from the spread but faces three risks:
1. Adverse selection — losing to informed traders 2. Inventory risk — accumulated directional exposure 3. Competition — other MMs narrowing the spread
Avellaneda-Stoikov Model
The optimal bid and ask quotes for a market maker with inventory q:
reservation_price = midprice - q * gamma * sigma^2 * T
optimal_spread = gamma * sigma^2 * T + (2/gamma) * ln(1 + gamma/k)Where:
q: current inventory (positive = long)gamma: risk aversion parametersigma: volatilityT: time remainingk: order arrival rate parameter
Key insight: the reservation price skews away from inventory — a long market maker lowers their price to encourage sells.
---
Execution Quality Measurement
VWAP Benchmark
Volume-Weighted Average Price is the standard benchmark for passive execution:
def vwap(prices: list[float], volumes: list[float]) -> float:
"""Compute VWAP from trade prices and volumes."""
pv_sum = sum(p * v for p, v in zip(prices, volumes))
v_sum = sum(volumes)
return pv_sum / v_sum if v_sum > 0 else 0.0
# Execution quality vs VWAP
slippage_vs_vwap = (avg_fill_price - vwap_benchmark) / vwap_benchmark * 10_000Implementation Shortfall
Measures total cost of executing vs the decision price (Perold, 1988):
implementation_shortfall = (execution_price - decision_price) * quantityDecomposes into:
- Delay cost: price drift between decision and first fill
- Market impact: price move caused by your order
- Timing cost: cost of breaking the order into slices
- Opportunity cost: value of unfilled portions
See references/execution_quality.md for complete methodology.
---
CEX Order Book vs DEX AMM
| Dimension | CEX (LOB) | DEX (AMM) |
|---|---|---|
| Price discovery | Limit orders express willingness to trade | Algorithmic curve (x·y=k) |
| Spread | Set by competing market makers | Determined by pool depth and fee tier |
| Depth | Visible order book | Implicit from TVL and curve shape |
| Adverse selection | MMs reprice on information | LPs suffer impermanent loss |
| Execution | Price-time priority | First-come via block inclusion |
| Latency | Microseconds | Block time (400ms Solana, 12s Ethereum) |
| MEV | Front-running is harder (colocated MMs) | Sandwich attacks are endemic |
| Fees | Maker/taker (often maker rebate) | Fixed tier (e.g., 5, 30, 100 bps) |
When to use CEX: large orders, latency-sensitive strategies, tight spreads needed, BTC/ETH/major pairs.
When to use DEX: long-tail tokens, censorship resistance, composability with DeFi, transparent execution.
See references/cex_vs_dex.md for detailed structural comparison.
---
Maker/Taker Fee Structures
CEX fee tiers create incentive asymmetries:
| Tier | Maker Fee | Taker Fee | Net Spread Required |
|---|---|---|---|
| VIP 0 | 0.10% | 0.10% | 20 bps to break even |
| VIP 5 | 0.02% | 0.05% | 7 bps to break even |
| VIP 9 | -0.005% | 0.03% | 2.5 bps + rebate income |
At high tiers, maker rebates mean market makers are paid to provide liquidity. This fundamentally changes strategy economics:
def maker_pnl_per_trade(
spread_captured_bps: float,
maker_fee_bps: float,
adverse_selection_bps: float,
) -> float:
"""Compute market maker P&L per round trip.
Args:
spread_captured_bps: Half-spread captured on each side.
maker_fee_bps: Maker fee (negative = rebate).
adverse_selection_bps: Expected loss to informed flow.
Returns:
Net P&L in basis points per round trip.
"""
gross = 2 * spread_captured_bps # earn half-spread on each leg
fees = 2 * maker_fee_bps # pay/receive fee on each leg
return gross - fees - adverse_selection_bps---
Files
References
references/price_formation.md— Glosten-Milgrom, Kyle model, PIN model, spread decompositionreferences/execution_quality.md— VWAP, TWAP, implementation shortfall, slippage decompositionreferences/cex_vs_dex.md— Structural comparison of LOB vs AMM, hybrid models, routing decisions
Scripts
scripts/spread_analysis.py— Analyze bid-ask spreads, compute effective/realized/quoted spread from trade data (--demo mode with synthetic order book)scripts/market_maker_sim.py— Market maker simulation with inventory management and P&L (--demo mode with synthetic price path)
---
Dependencies
uv pip install numpy pandas scipy matplotlib---
Related Skills
- market-microstructure — On-chain DEX microstructure (AMM-specific)
- slippage-modeling — Execution cost estimation and modeling
- liquidity-analysis — Pool and order book depth analysis
- order-execution — Practical execution algorithms
- mev-analysis — MEV risk in on-chain execution
CEX Order Book vs DEX AMM — Structural Comparison
A detailed comparison of the two dominant market structures in crypto: centralized exchange limit order books (LOB) and decentralized exchange automated market makers (AMM).
---
Price Discovery
LOB (CEX)
Prices emerge from the intersection of buyer and seller limit orders. Market makers actively quote two-sided markets. Price reflects the consensus of all participants' information.
Advantages: precise price expression, rapid information incorporation, sub-millisecond updates.
AMM (DEX)
Prices are determined by a mathematical bonding curve (e.g., x*y=k for constant product). Price moves only when trades occur.
Advantages: always-on liquidity, no dependence on active market makers, permissionless listing.
Key difference: LOB prices update between trades (quote revisions); AMM prices only update on trades. This makes AMMs slower to incorporate information, creating arbitrage opportunities that CEX-DEX arb bots exploit.
---
Spread and Liquidity
LOB Spread
Set by competing market makers. Narrower spreads indicate:
- More MM competition
- Higher volume (MMs can recoup costs faster)
- Lower volatility (lower inventory risk)
- Better fee tiers (maker rebates subsidize tighter quotes)
Typical BTC/USDT spread on Binance: 0.5-1 bps.
AMM Implicit Spread
The AMM's "spread" comes from the fee tier plus the curve's price sensitivity:
implicit_spread ≈ 2 * fee_tier + price_impact_of_min_tradeFor concentrated liquidity (Uniswap V3, Orca Whirlpools):
effective_spread ≈ 2 * fee_tier / sqrt(concentration_factor)Higher concentration = tighter effective spread but more impermanent loss.
Typical SOL/USDC spread on Orca (5 bps fee tier): 10-15 bps.
---
Order Types
LOB
| Order Type | Behavior |
|---|---|
| Limit | Rests on book at specified price |
| Market | Fills immediately at best available |
| Stop-limit | Becomes limit when trigger price hit |
| IOC | Fill what you can, cancel the rest |
| FOK | Fill entirely or cancel |
| Post-only | Reject if would cross the spread |
| Iceberg | Shows only partial size |
AMM
| Order Type | Behavior |
|---|---|
| Swap | Market order against the pool |
| Limit (via protocol) | Some DEXes offer limit orders (Jupiter, Serum) |
| DCA | Split order over time (Jupiter DCA) |
Key difference: LOBs offer far more order type flexibility. AMMs are essentially market-order-only, though Jupiter and other aggregators add limit order and DCA features on top.
---
Adverse Selection
LOB: Market Maker vs Informed Trader
MMs manage adverse selection by:
- Widening spreads during high-vol periods
- Reducing quote size when information asymmetry is high
- Skewing quotes away from inventory
- Cancelling and repricing in response to correlated asset moves
MM losses to informed flow are compensated by profits from uninformed flow. The balance determines whether market making is profitable.
AMM: Liquidity Provider vs Arbitrageur
LPs face adverse selection through impermanent loss — arbitrageurs trade against the pool whenever the AMM price diverges from the true market price.
Key differences from LOB adverse selection:
- LPs cannot reprice (the curve is fixed for a given position)
- LPs cannot selectively avoid informed flow
- Loss is mechanical and predictable, not probabilistic
- Concentrated liquidity amplifies both fee income and IL
LP_return = fee_income - impermanent_loss - opportunity_cost---
Execution Latency
| Venue | Latency | Implications |
|---|---|---|
| Binance (colocated) | < 1 ms | HFT viable, speed advantage matters |
| Binance (API) | 5-50 ms | Fast enough for most strategies |
| Solana DEX | 400 ms (slot time) | Block-level granularity, MEV risk |
| Ethereum DEX | 12 s (block time) | Significant delay, high MEV risk |
Implication: latency-sensitive strategies (stat arb, market making) are only viable on CEX or fast L1/L2 chains. Solana's 400ms slots make it the most viable chain for on-chain market making.
---
MEV and Fairness
CEX
- Front-running is difficult (exchange controls matching engine)
- Colocated traders have speed advantage but cannot see pending orders
- Exchange may run proprietary trading desks (conflict of interest)
DEX
- Pending transactions are visible in the mempool
- Sandwich attacks extract value from large swaps
- MEV bots compete via priority fees / Jito bundles on Solana
- Mitigation: private mempools, MEV protection (Flashbots Protect, Jito)
Sandwich attack cost: typically 10-100 bps on large DEX swaps. Using MEV protection can eliminate this but may increase latency.
---
Fee Structures
CEX: Maker/Taker Model
maker_fee: 0.00% to 0.10% (often rebates at high tiers)
taker_fee: 0.03% to 0.10%Makers (limit orders that add liquidity) pay less or receive rebates. Takers (market orders that remove liquidity) pay more.
DEX: Fixed Fee Tiers
Typical tiers: 1 bps, 5 bps, 30 bps, 100 bpsFees go to LPs. No maker/taker distinction — all trades are "taker" against the pool. Protocol may take a cut (e.g., Uniswap takes some fee switch revenue).
Cost comparison for a $10K trade:
| Venue | Spread Cost | Fee | MEV Cost | Total |
|---|---|---|---|---|
| Binance (taker) | 1 bps | 10 bps | 0 | ~11 bps |
| Orca (5 bps pool) | 5 bps | 5 bps | 0-20 bps | 10-30 bps |
CEX is generally cheaper for large trades on major pairs. DEX can be competitive for small trades on long-tail tokens.
---
Hybrid Models
CLOB on Chain (Phoenix, OpenBook)
On-chain central limit order books that combine LOB mechanics with blockchain settlement. Solana's speed makes this viable.
Tradeoffs: LOB flexibility + on-chain transparency, but limited by blockchain throughput and vulnerable to MEV.
AMM + Order Book (Raydium)
Raydium's hybrid model routes liquidity between an AMM pool and the OpenBook order book. Provides AMM convenience with LOB depth.
Intent-Based Systems (Jupiter, CoW Protocol)
Orders express intent ("swap X for Y"), and solvers/aggregators find the best execution path across multiple venues.
Advantages: MEV protection, optimal routing, price improvement. Disadvantages: solver centralization risk, latency.
---
When to Route Where
Use CEX When:
- Trading BTC, ETH, SOL, or other major pairs
- Order size > $50K (better depth and lower impact)
- Latency matters (market making, arb)
- You need advanced order types (stop-loss, iceberg)
- Fee tier gives you maker rebates
Use DEX When:
- Trading long-tail tokens not listed on CEX
- Composability with DeFi is needed (flash loans, MEV strategies)
- Censorship resistance is required
- Trade size is small (< $10K) on liquid pools
- You want transparent, verifiable execution
Use Aggregator When:
- You want best execution across multiple DEX venues
- Order size is moderate and could benefit from split routing
- You want MEV protection (Jupiter with Jito integration)
---
Monitoring Cross-Venue Execution
Track these metrics to evaluate venue quality:
@dataclass
class VenueMetrics:
"""Execution quality metrics for a single venue."""
venue: str
avg_slippage_bps: float # vs midprice at decision time
avg_spread_bps: float # observed spread
fill_rate: float # fraction of orders fully filled
avg_latency_ms: float # decision to fill
fee_bps: float # average fee paid
mev_cost_bps: float # estimated MEV extraction (DEX)
@property
def total_cost_bps(self) -> float:
return self.avg_slippage_bps + self.fee_bps + self.mev_cost_bpsCompare venues on total cost, not just spread. A venue with tighter spreads but higher fees and MEV costs may be worse overall.
Execution Quality Measurement
How to evaluate whether your trades were executed well. This reference covers standard benchmarks, slippage decomposition, and practical measurement.
---
Benchmarks
VWAP — Volume-Weighted Average Price
The most common benchmark for passive execution. VWAP represents the average price weighted by volume over a period:
VWAP = Σ(price_i * volume_i) / Σ(volume_i)When to use: evaluating execution of orders that are not time-sensitive. VWAP is a fair benchmark when you have discretion over execution timing within a window.
Limitations: VWAP is manipulable (your own trades affect it), does not penalize delay, and is meaningless for orders that dominate the volume.
import numpy as np
def compute_vwap(
prices: np.ndarray,
volumes: np.ndarray,
) -> float:
"""Compute VWAP from arrays of prices and volumes."""
return float(np.sum(prices * volumes) / np.sum(volumes))
def vwap_slippage_bps(
fill_price: float,
vwap: float,
side: str,
) -> float:
"""Compute slippage vs VWAP in basis points.
Positive = you paid more than VWAP (bad for buys).
"""
sign = 1.0 if side == "buy" else -1.0
return sign * (fill_price - vwap) / vwap * 10_000TWAP — Time-Weighted Average Price
Simple average of prices at equal time intervals. Less common as a benchmark but used as an execution strategy:
TWAP = (1/N) * Σ(price_i)When to use: when volume profile is unknown or irrelevant.
Arrival Price (Decision Price)
The midprice at the moment you decide to trade. This is the theoretically correct benchmark because it captures the full cost of execution including delay and market impact.
arrival_slippage = (avg_fill_price - arrival_price) * trade_signPrevious Close
Used mainly in TradFi for overnight order evaluation. Less relevant for 24/7 crypto markets, but applicable for comparing daily strategy signals.
---
Implementation Shortfall (Perold, 1988)
The total cost of turning a trading decision into a completed position. Decomposes into four components:
Formula
IS = (execution_price - decision_price) * filled_qty
+ (closing_price - decision_price) * unfilled_qtyDecomposition
| Component | Formula | What It Measures |
|---|---|---|
| Delay cost | (broker_price - decision_price) * qty | Cost of waiting to start |
| Market impact | (avg_fill - broker_price) * filled_qty | Your order moving the price |
| Timing cost | Σ(slice_price - arrival_price) * slice_qty | Cost of splitting over time |
| Opportunity cost | (close_price - decision_price) * unfilled_qty | Cost of not filling entirely |
Example
Decision price: $100.00 (midprice when you decide to buy 1000 shares)
Order start: $100.05 (30 seconds delay)
Fill 1: $100.10 (500 shares)
Fill 2: $100.15 (400 shares)
Unfilled: 100 shares (market moved to $100.50 by close)
Delay cost: ($100.05 - $100.00) * 1000 = $50
Market impact: ($100.12 - $100.05) * 900 = $63
Opportunity: ($100.50 - $100.00) * 100 = $50
Total IS: $163 (16.3 bps on a $100K order)---
Slippage Decomposition for Crypto
Crypto-specific slippage sources:
1. Spread Cost
The bid-ask spread you cross to get a fill.
spread_cost = 0.5 * quoted_spread * trade_sign2. Depth Cost (Market Impact)
Consuming liquidity beyond the top of book.
def depth_cost(
order_size: float,
book_levels: list[tuple[float, float]],
) -> float:
"""Compute cost of walking the book.
Args:
order_size: Quantity to fill.
book_levels: List of (price, quantity) tuples from best to worst.
Returns:
Average fill price.
"""
filled = 0.0
cost = 0.0
for price, qty in book_levels:
fill_at_level = min(qty, order_size - filled)
cost += fill_at_level * price
filled += fill_at_level
if filled >= order_size:
break
return cost / filled if filled > 0 else 0.03. Timing Cost
Price drift during multi-slice execution.
4. Fee Cost
Maker/taker fees on CEX, swap fees on DEX.
5. MEV Cost (DEX only)
Sandwich attacks and front-running on-chain.
Total Slippage
total_slippage = spread_cost + depth_cost + timing_cost + fee_cost + mev_cost---
Measuring Execution Quality in Practice
Step 1: Record Decision Points
For every trade, log:
- Decision timestamp and midprice
- Order submission timestamp
- Each fill: timestamp, price, quantity, fee
- Order completion or cancellation timestamp
Step 2: Compute Benchmarks
from dataclasses import dataclass
@dataclass
class ExecutionReport:
"""Execution quality report for a single order."""
decision_price: float
avg_fill_price: float
vwap_benchmark: float
filled_qty: float
total_qty: float
fees_paid: float
@property
def fill_rate(self) -> float:
return self.filled_qty / self.total_qty if self.total_qty > 0 else 0.0
@property
def is_bps(self) -> float:
"""Implementation shortfall in basis points."""
return (self.avg_fill_price - self.decision_price) / self.decision_price * 10_000
@property
def vs_vwap_bps(self) -> float:
"""Slippage vs VWAP in basis points."""
return (self.avg_fill_price - self.vwap_benchmark) / self.vwap_benchmark * 10_000
@property
def total_cost_bps(self) -> float:
"""Total execution cost including fees."""
fee_bps = self.fees_paid / (self.avg_fill_price * self.filled_qty) * 10_000
return self.is_bps + fee_bpsStep 3: Aggregate and Compare
Track execution quality over time:
- By venue (which exchange gives best fills?)
- By time of day (when is liquidity best?)
- By order size bucket (how does impact scale?)
- By urgency (does rushing cost more than waiting?)
Step 4: A/B Test Execution Strategies
Split order flow between strategies and compare:
def execution_ab_test(
strategy_a_slippages: list[float],
strategy_b_slippages: list[float],
) -> dict:
"""Compare two execution strategies via t-test.
Args:
strategy_a_slippages: Slippage in bps for strategy A fills.
strategy_b_slippages: Slippage in bps for strategy B fills.
Returns:
Dict with mean difference, t-statistic, p-value.
"""
from scipy import stats
t_stat, p_value = stats.ttest_ind(
strategy_a_slippages,
strategy_b_slippages,
equal_var=False, # Welch's t-test
)
return {
"mean_a_bps": float(np.mean(strategy_a_slippages)),
"mean_b_bps": float(np.mean(strategy_b_slippages)),
"difference_bps": float(np.mean(strategy_a_slippages) - np.mean(strategy_b_slippages)),
"t_statistic": float(t_stat),
"p_value": float(p_value),
}---
Practical Tips for Crypto
1. Always measure vs arrival price, not vs VWAP alone — VWAP can hide delay costs and adverse selection.
2. Account for fees in all comparisons — a 2 bps spread improvement is worthless if the venue charges 5 bps more in fees.
3. Measure realized spread with 5-30 second delays — this captures the adverse selection you face.
4. Track fill rates — a strategy that gets great prices but only fills 50% of the time may cost more in opportunity than one with worse fills but 100% completion.
5. Beware survivorship bias — don't only measure completed orders. Cancelled or partially filled orders often had the worst expected execution (that's why they weren't filled).
6. On DEX, include MEV cost — check if your transaction was sandwiched by comparing your fill to the pre-transaction pool state.
Price Formation Models
How orders become prices. This reference covers the foundational models of price formation in limit order book markets, adapted for crypto context.
---
Glosten-Milgrom Model (1985)
Setup
- An asset has true value V, known to informed traders but not the market maker
- Traders arrive sequentially: informed (probability π) or uninformed (1-π)
- The market maker posts bid and ask prices, updating after each trade
Equilibrium Conditions
The market maker sets prices to break even in expectation:
ask = E[V | next trade is a buy]
bid = E[V | next trade is a sell]Since buys are more likely from informed traders when V is high:
ask > E[V] > bidThe spread arises purely from adverse selection — the MM must be compensated for the risk of trading against someone who knows more.
Spread Determinants
The spread widens when:
- π increases — more informed traders in the population
- Information asymmetry increases — informed traders know more
- Uninformed volume drops — less "good" flow to offset losses
Bayesian Updating
After observing a buy at the ask:
P(V=high | buy) = P(buy | V=high) * P(V=high) / P(buy)The market maker updates beliefs and revises quotes. This creates the permanent price impact of trades — prices move because trades convey information.
Crypto Application
On CEX order books, the Glosten-Milgrom intuition explains why:
- Spreads widen before major announcements (higher π expected)
- Illiquid altcoins have wider spreads (fewer uninformed traders)
- Spreads widen during volatility spikes (information asymmetry rises)
---
Kyle Model (1985)
Setup
Three types of participants: 1. Informed trader — knows true value V, trades optimally 2. Noise traders — trade randomly with volume u ~ N(0, σ_u²) 3. Market maker — observes total order flow, sets price
Equilibrium
The market maker sets price as a linear function of net order flow:
P = μ + λ * (x + u)Where:
- μ: prior expected value
- λ: Kyle's lambda (price impact coefficient)
- x: informed trader's order
- u: noise trader volume
Kyle's Lambda
λ = σ_v / (2 * σ_u)Where σ_v is the standard deviation of the asset's true value and σ_u is the standard deviation of noise trading.
Interpretation: lambda is the price impact per unit of signed volume. Higher lambda means less liquidity.
Estimation from Data
Regress price changes on signed order flow in fixed intervals:
import numpy as np
def estimate_kyle_lambda(
midprice_changes: np.ndarray,
signed_volumes: np.ndarray,
interval_seconds: int = 60,
) -> dict:
"""Estimate Kyle's lambda from aggregated trade data.
Args:
midprice_changes: Change in midprice per interval.
signed_volumes: Net signed volume per interval.
interval_seconds: Aggregation interval.
Returns:
Dict with lambda estimate, R-squared, t-statistic.
"""
X = np.column_stack([np.ones(len(signed_volumes)), signed_volumes])
beta, residuals, _, _ = np.linalg.lstsq(X, midprice_changes, rcond=None)
y_hat = X @ beta
ss_res = np.sum((midprice_changes - y_hat) ** 2)
ss_tot = np.sum((midprice_changes - midprice_changes.mean()) ** 2)
r_squared = 1 - ss_res / ss_tot if ss_tot > 0 else 0.0
n = len(signed_volumes)
se = np.sqrt(ss_res / (n - 2) / np.sum((signed_volumes - signed_volumes.mean()) ** 2))
t_stat = beta[1] / se if se > 0 else 0.0
return {
"lambda": float(beta[1]),
"intercept": float(beta[0]),
"r_squared": float(r_squared),
"t_statistic": float(t_stat),
"n_observations": n,
}Typical Values
| Market | Lambda (bps per $1M flow) | Notes |
|---|---|---|
| BTC/USDT (Binance) | 0.5-2 | Deep liquidity |
| ETH/USDT (Binance) | 1-4 | Moderate |
| Mid-cap altcoins | 10-50 | Thin books |
| Small-cap altcoins | 50-500 | Very illiquid |
---
PIN Model — Probability of Informed Trading
The PIN model (Easley, Kiefer, O'Hara, 1996) estimates the probability that any given trade is from an informed trader.
Parameters
- α: probability of an information event on a given day
- δ: probability the event is bad news (given an event occurs)
- μ: arrival rate of informed traders (when event occurs)
- ε_b: arrival rate of uninformed buy orders
- ε_s: arrival rate of uninformed sell orders
PIN Formula
PIN = (α * μ) / (α * μ + ε_b + ε_s)Estimation
PIN is estimated via maximum likelihood on daily buy/sell counts. The likelihood for a single day with B buys and S sells:
L(B,S) = (1-α) * f(B|ε_b) * f(S|ε_s)
+ α*δ * f(B|ε_b) * f(S|ε_s + μ) # bad news day
+ α*(1-δ) * f(B|ε_b + μ) * f(S|ε_s) # good news dayWhere f(k|λ) is the Poisson PMF.
Crypto Application
- High PIN tokens have wider spreads and more adverse selection
- PIN tends to spike before token unlocks, exchange listings, governance votes
- Can be computed from CEX trade data by classifying trades via tick rule
def classify_trades_tick_rule(
prices: list[float],
) -> list[int]:
"""Classify trades as buys (+1) or sells (-1) via tick rule.
Args:
prices: Sequence of trade prices.
Returns:
List of trade signs.
"""
signs = [1] # first trade is ambiguous, default to buy
for i in range(1, len(prices)):
if prices[i] > prices[i - 1]:
signs.append(1)
elif prices[i] < prices[i - 1]:
signs.append(-1)
else:
signs.append(signs[-1]) # repeat last sign
return signs---
Spread Decomposition
Three-Component Model (Stoll, 1989)
quoted_spread = adverse_selection + inventory_cost + order_processingEmpirical Decomposition (Huang-Stoll, 1997)
Estimate from the covariance of trade signs and quote revisions:
quote_revision = α * S/2 * q_t + noiseWhere α is the adverse selection fraction of the half-spread and q_t is the trade sign. The inventory component β is estimated from:
E[q_{t+1} | q_t] = 1 - 2*(β + α)Practical Shortcut: Effective vs Realized Spread
adverse_selection = effective_spread - realized_spreadThe effective spread is what the trader pays; the realized spread (measured with a delay) is what the market maker keeps. The difference is adverse selection — profits lost to informed traders.
Typical delay: 5-30 seconds for crypto (one to several blocks on DEX).
#!/usr/bin/env python3
"""Market maker simulation with inventory management and P&L tracking.
Simulates a simple market maker using the Avellaneda-Stoikov framework.
The MM quotes bid/ask prices that skew based on inventory, captures spread,
and faces adverse selection from informed traders. Includes --demo mode.
Usage:
python scripts/market_maker_sim.py --demo
python scripts/market_maker_sim.py --demo --gamma 0.1 --n-steps 5000
Dependencies:
uv pip install numpy pandas matplotlib
Environment Variables:
None required for --demo mode.
"""
import argparse
import sys
from dataclasses import dataclass, field
from typing import Optional
import numpy as np
import pandas as pd
# ── Data Structures ─────────────────────────────────────────────────
@dataclass
class MarketMakerState:
"""Current state of the market maker."""
cash: float = 0.0
inventory: int = 0
n_trades: int = 0
n_buys_filled: int = 0
n_sells_filled: int = 0
total_spread_captured: float = 0.0
total_adverse_selection_loss: float = 0.0
max_inventory: int = 0
min_inventory: int = 0
@dataclass
class SimulationConfig:
"""Configuration for the market maker simulation."""
n_steps: int = 2000
initial_price: float = 100.0
volatility: float = 0.001
gamma: float = 0.05 # risk aversion
k: float = 1.5 # order arrival intensity
base_spread_bps: float = 10.0 # minimum spread
max_inventory: int = 50 # inventory limit
informed_fraction: float = 0.15
trade_probability: float = 0.3
seed: int = 42
@dataclass
class TradeRecord:
"""Record of a single fill."""
step: int
side: str # "buy" or "sell" (from MM perspective)
price: float
midprice: float
inventory_before: int
inventory_after: int
pnl: float # immediate mark-to-market P&L
is_informed: bool
@dataclass
class SimulationResult:
"""Complete simulation output."""
config: SimulationConfig
prices: np.ndarray
inventories: np.ndarray
pnl_series: np.ndarray
bid_prices: np.ndarray
ask_prices: np.ndarray
trades: list[TradeRecord]
final_state: MarketMakerState
# ── Price Simulation ────────────────────────────────────────────────
def simulate_price_path(
n_steps: int,
initial_price: float,
volatility: float,
seed: int = 42,
) -> np.ndarray:
"""Generate a geometric Brownian motion price path.
Args:
n_steps: Number of time steps.
initial_price: Starting price.
volatility: Per-step return volatility.
seed: Random seed.
Returns:
Array of prices.
"""
rng = np.random.default_rng(seed)
returns = rng.normal(0, volatility, n_steps)
# Add small drift and occasional jumps for realism
jumps = rng.choice([0, 1], size=n_steps, p=[0.98, 0.02])
jump_sizes = rng.normal(0, volatility * 5, n_steps) * jumps
returns += jump_sizes
prices = initial_price * np.exp(np.cumsum(returns))
return prices
# ── Avellaneda-Stoikov Quoting ──────────────────────────────────────
def compute_optimal_quotes(
midprice: float,
inventory: int,
gamma: float,
sigma: float,
time_remaining: float,
k: float,
min_spread_bps: float = 5.0,
) -> tuple[float, float]:
"""Compute optimal bid and ask using Avellaneda-Stoikov model.
The reservation price shifts away from inventory, and the optimal
spread depends on volatility and risk aversion.
Args:
midprice: Current fair price.
inventory: Current inventory (positive = long).
gamma: Risk aversion parameter.
sigma: Volatility (per step).
time_remaining: Fraction of session remaining (0 to 1).
k: Order arrival rate parameter.
min_spread_bps: Minimum spread floor in basis points.
Returns:
Tuple of (bid_price, ask_price).
"""
# Reservation price: skew away from inventory
T = max(time_remaining, 0.001)
reservation_price = midprice - inventory * gamma * (sigma ** 2) * T
# Optimal spread
spread = gamma * (sigma ** 2) * T + (2.0 / gamma) * np.log(1 + gamma / k)
# Apply minimum spread
min_spread = midprice * min_spread_bps / 10_000
spread = max(spread, min_spread)
bid = reservation_price - spread / 2
ask = reservation_price + spread / 2
return bid, ask
# ── Simulation Engine ───────────────────────────────────────────────
def run_simulation(config: SimulationConfig) -> SimulationResult:
"""Run a complete market maker simulation.
Args:
config: Simulation parameters.
Returns:
SimulationResult with full history.
"""
rng = np.random.default_rng(config.seed)
# Generate true price path
true_prices = simulate_price_path(
config.n_steps, config.initial_price, config.volatility, config.seed
)
# State tracking arrays
inventories = np.zeros(config.n_steps)
pnl_series = np.zeros(config.n_steps)
bid_prices = np.zeros(config.n_steps)
ask_prices = np.zeros(config.n_steps)
state = MarketMakerState()
trades: list[TradeRecord] = []
for t in range(config.n_steps):
midprice = true_prices[t]
time_remaining = (config.n_steps - t) / config.n_steps
# Compute quotes
bid, ask = compute_optimal_quotes(
midprice=midprice,
inventory=state.inventory,
gamma=config.gamma,
sigma=config.volatility,
time_remaining=time_remaining,
k=config.k,
min_spread_bps=config.base_spread_bps,
)
# Enforce inventory limits by widening quotes
if state.inventory >= config.max_inventory:
bid = midprice * 0.99 # very low bid, discourage more buying
if state.inventory <= -config.max_inventory:
ask = midprice * 1.01 # very high ask, discourage more selling
bid_prices[t] = bid
ask_prices[t] = ask
# Simulate incoming order
if rng.random() < config.trade_probability:
is_informed = rng.random() < config.informed_fraction
if is_informed:
# Informed trader knows future direction
future_idx = min(t + 50, config.n_steps - 1)
future_price = true_prices[future_idx]
# Buy if price is going up, sell if going down
incoming_side = "buy" if future_price > midprice else "sell"
else:
incoming_side = "buy" if rng.random() > 0.5 else "sell"
if incoming_side == "buy" and ask <= midprice * 1.005:
# Incoming buy lifts our ask -> we sell
fill_price = ask
state.cash += fill_price
state.inventory -= 1
state.n_sells_filled += 1
state.n_trades += 1
spread_captured = fill_price - midprice
state.total_spread_captured += spread_captured
trades.append(TradeRecord(
step=t,
side="sell",
price=fill_price,
midprice=midprice,
inventory_before=state.inventory + 1,
inventory_after=state.inventory,
pnl=spread_captured,
is_informed=is_informed,
))
elif incoming_side == "sell" and bid >= midprice * 0.995:
# Incoming sell hits our bid -> we buy
fill_price = bid
state.cash -= fill_price
state.inventory += 1
state.n_buys_filled += 1
state.n_trades += 1
spread_captured = midprice - fill_price
state.total_spread_captured += spread_captured
trades.append(TradeRecord(
step=t,
side="buy",
price=fill_price,
midprice=midprice,
inventory_before=state.inventory - 1,
inventory_after=state.inventory,
pnl=spread_captured,
is_informed=is_informed,
))
# Track state
state.max_inventory = max(state.max_inventory, state.inventory)
state.min_inventory = min(state.min_inventory, state.inventory)
inventories[t] = state.inventory
# Mark-to-market P&L: cash + inventory * midprice
pnl_series[t] = state.cash + state.inventory * midprice
# Compute adverse selection loss from informed trades
informed_trades = [tr for tr in trades if tr.is_informed]
for tr in informed_trades:
future_idx = min(tr.step + 50, config.n_steps - 1)
future_mid = true_prices[future_idx]
if tr.side == "sell":
# We sold; if price went up, we lost
loss = max(0, future_mid - tr.price)
else:
# We bought; if price went down, we lost
loss = max(0, tr.price - future_mid)
state.total_adverse_selection_loss += loss
return SimulationResult(
config=config,
prices=true_prices,
inventories=inventories,
pnl_series=pnl_series,
bid_prices=bid_prices,
ask_prices=ask_prices,
trades=trades,
final_state=state,
)
# ── Reporting ───────────────────────────────────────────────────────
def print_report(result: SimulationResult) -> None:
"""Print a summary report of the simulation.
Args:
result: Completed simulation result.
"""
state = result.final_state
config = result.config
final_mid = result.prices[-1]
final_pnl = state.cash + state.inventory * final_mid
print("=" * 55)
print(" MARKET MAKER SIMULATION REPORT")
print("=" * 55)
print()
print(f" Configuration:")
print(f" Steps: {config.n_steps:,}")
print(f" Initial price: ${config.initial_price:.2f}")
print(f" Volatility: {config.volatility:.4f} per step")
print(f" Risk aversion (γ): {config.gamma:.3f}")
print(f" Informed fraction: {config.informed_fraction:.0%}")
print(f" Max inventory: ±{config.max_inventory}")
print()
print(f" Trading Activity:")
print(f" Total fills: {state.n_trades:,}")
print(f" Buys (we bought): {state.n_buys_filled:,}")
print(f" Sells (we sold): {state.n_sells_filled:,}")
print(f" Fill rate: {state.n_trades / config.n_steps:.1%} of steps")
print()
print(f" Inventory:")
print(f" Final inventory: {state.inventory:+d}")
print(f" Max inventory: {state.max_inventory:+d}")
print(f" Min inventory: {state.min_inventory:+d}")
print()
print(f" P&L Breakdown:")
print(f" Spread captured: ${state.total_spread_captured:+.2f}")
print(f" Adverse selection: ${-state.total_adverse_selection_loss:.2f}")
print(f" Inventory value: ${state.inventory * final_mid:+.2f}")
print(f" Final M2M P&L: ${final_pnl:+.2f}")
print()
# Per-trade statistics
if result.trades:
spreads = [abs(tr.price - tr.midprice) / tr.midprice * 10_000
for tr in result.trades]
print(f" Per-Trade Stats:")
print(f" Avg spread captured: {np.mean(spreads):.2f} bps")
print(f" Median: {np.median(spreads):.2f} bps")
print(f" Std: {np.std(spreads):.2f} bps")
informed_count = sum(1 for tr in result.trades if tr.is_informed)
print(f" Informed fills: {informed_count} ({informed_count/len(result.trades)*100:.1f}%)")
print()
# Sharpe-like metric
if len(result.pnl_series) > 1:
pnl_returns = np.diff(result.pnl_series)
if np.std(pnl_returns) > 0:
sharpe = np.mean(pnl_returns) / np.std(pnl_returns) * np.sqrt(252 * 24 * 60)
print(f" Risk-Adjusted (annualized Sharpe estimate): {sharpe:.2f}")
print()
def plot_simulation(
result: SimulationResult,
output_path: Optional[str] = None,
) -> None:
"""Plot simulation results in a 4-panel chart.
Args:
result: Completed simulation result.
output_path: If provided, save to file instead of showing.
"""
try:
import matplotlib.pyplot as plt
except ImportError:
print("matplotlib not installed, skipping plot.")
return
fig, axes = plt.subplots(4, 1, figsize=(14, 12), sharex=True)
steps = np.arange(result.config.n_steps)
# Panel 1: Price and quotes
ax1 = axes[0]
ax1.plot(steps, result.prices, color="black", linewidth=0.5, label="True Price")
ax1.plot(steps, result.bid_prices, color="blue", linewidth=0.3, alpha=0.5, label="Bid")
ax1.plot(steps, result.ask_prices, color="red", linewidth=0.3, alpha=0.5, label="Ask")
buy_trades = [tr for tr in result.trades if tr.side == "buy"]
sell_trades = [tr for tr in result.trades if tr.side == "sell"]
if buy_trades:
ax1.scatter([tr.step for tr in buy_trades],
[tr.price for tr in buy_trades],
color="green", s=8, alpha=0.6, label="We Buy", zorder=5)
if sell_trades:
ax1.scatter([tr.step for tr in sell_trades],
[tr.price for tr in sell_trades],
color="red", s=8, alpha=0.6, label="We Sell", zorder=5)
ax1.set_ylabel("Price")
ax1.set_title("Market Maker Simulation — Price and Quotes")
ax1.legend(loc="upper left", fontsize=7)
# Panel 2: Inventory
ax2 = axes[1]
ax2.fill_between(steps, 0, result.inventories, alpha=0.4,
color="steelblue", label="Inventory")
ax2.axhline(y=0, color="black", linewidth=0.5)
ax2.axhline(y=result.config.max_inventory, color="red",
linewidth=0.5, linestyle="--", label="Limit")
ax2.axhline(y=-result.config.max_inventory, color="red",
linewidth=0.5, linestyle="--")
ax2.set_ylabel("Inventory")
ax2.set_title("Inventory Over Time")
ax2.legend(fontsize=7)
# Panel 3: Mark-to-Market P&L
ax3 = axes[2]
ax3.plot(steps, result.pnl_series, color="darkgreen", linewidth=0.8)
ax3.axhline(y=0, color="black", linewidth=0.5)
ax3.set_ylabel("P&L ($)")
ax3.set_title("Cumulative Mark-to-Market P&L")
# Panel 4: Spread over time
ax4 = axes[3]
spread_bps = (result.ask_prices - result.bid_prices) / result.prices * 10_000
ax4.plot(steps, spread_bps, color="purple", linewidth=0.5)
ax4.axhline(y=np.mean(spread_bps), color="red", linestyle="--",
label=f"Mean: {np.mean(spread_bps):.1f} bps")
ax4.set_ylabel("Spread (bps)")
ax4.set_xlabel("Time Step")
ax4.set_title("Quoted Spread Over Time")
ax4.legend(fontsize=7)
plt.tight_layout()
if output_path:
plt.savefig(output_path, dpi=150)
print(f"Plot saved to {output_path}")
else:
plt.show()
# ── Main ────────────────────────────────────────────────────────────
def main() -> None:
"""Run market maker simulation from command line."""
parser = argparse.ArgumentParser(
description="Market maker simulation with Avellaneda-Stoikov quoting"
)
parser.add_argument(
"--demo", action="store_true",
help="Run with synthetic price path (no API key needed)",
)
parser.add_argument("--n-steps", type=int, default=2000, help="Simulation steps")
parser.add_argument("--price", type=float, default=100.0, help="Initial price")
parser.add_argument("--volatility", type=float, default=0.001, help="Per-step volatility")
parser.add_argument("--gamma", type=float, default=0.05, help="Risk aversion (0.01-1.0)")
parser.add_argument("--informed", type=float, default=0.15, help="Informed trader fraction")
parser.add_argument("--max-inv", type=int, default=50, help="Max inventory limit")
parser.add_argument("--seed", type=int, default=42, help="Random seed")
parser.add_argument("--plot", action="store_true", help="Show plots")
parser.add_argument("--save-plot", type=str, default=None, help="Save plot to file")
args = parser.parse_args()
if not args.demo:
print("Use --demo to run the simulation. Use --help for options.")
sys.exit(1)
config = SimulationConfig(
n_steps=args.n_steps,
initial_price=args.price,
volatility=args.volatility,
gamma=args.gamma,
informed_fraction=args.informed,
max_inventory=args.max_inv,
seed=args.seed,
)
print(f"Running market maker simulation ({config.n_steps:,} steps)...")
print()
result = run_simulation(config)
print_report(result)
# Parameter sensitivity analysis
print("=== Gamma Sensitivity ===")
print(f" {'Gamma':>8} {'Trades':>8} {'Final P&L':>12} {'Avg Spread':>12}")
print(f" {'─' * 8} {'─' * 8} {'─' * 12} {'─' * 12}")
for g in [0.01, 0.05, 0.1, 0.2, 0.5]:
test_config = SimulationConfig(
n_steps=config.n_steps,
initial_price=config.initial_price,
volatility=config.volatility,
gamma=g,
informed_fraction=config.informed_fraction,
max_inventory=config.max_inventory,
seed=config.seed,
)
test_result = run_simulation(test_config)
final_pnl = test_result.final_state.cash + test_result.final_state.inventory * test_result.prices[-1]
avg_spread = np.mean(
(test_result.ask_prices - test_result.bid_prices) / test_result.prices * 10_000
)
print(f" {g:>8.3f} {test_result.final_state.n_trades:>8,} ${final_pnl:>+11.2f} {avg_spread:>10.1f} bps")
print()
if args.plot or args.save_plot:
plot_simulation(result, output_path=args.save_plot)
if __name__ == "__main__":
main()
#!/usr/bin/env python3
"""Bid-ask spread analysis: compute quoted, effective, and realized spreads.
Analyzes order book and trade data to decompose spreads into adverse selection,
inventory, and order processing components. Includes --demo mode with synthetic
data so no API key is required to run.
Usage:
python scripts/spread_analysis.py --demo
python scripts/spread_analysis.py --input trades.csv
Dependencies:
uv pip install numpy pandas matplotlib
Environment Variables:
None required for --demo mode.
"""
import argparse
import sys
from dataclasses import dataclass, field
from typing import Optional
import numpy as np
import pandas as pd
# ── Data Structures ─────────────────────────────────────────────────
@dataclass
class SpreadMetrics:
"""Aggregated spread metrics for a trading session."""
quoted_spread_bps: float
effective_spread_bps: float
realized_spread_bps: float
adverse_selection_bps: float
price_impact_bps: float
n_trades: int
def summary(self) -> str:
"""Return a human-readable summary."""
lines = [
"=== Spread Analysis Summary ===",
f" Trades analyzed: {self.n_trades:,}",
f" Quoted spread: {self.quoted_spread_bps:.2f} bps",
f" Effective spread: {self.effective_spread_bps:.2f} bps",
f" Realized spread (5s): {self.realized_spread_bps:.2f} bps",
f" Adverse selection: {self.adverse_selection_bps:.2f} bps",
f" Avg price impact: {self.price_impact_bps:.2f} bps",
]
return "\n".join(lines)
# ── Synthetic Data Generation ───────────────────────────────────────
def generate_synthetic_orderbook(
n_snapshots: int = 1000,
base_price: float = 100.0,
base_spread_bps: float = 10.0,
volatility: float = 0.001,
seed: int = 42,
) -> pd.DataFrame:
"""Generate synthetic order book snapshots with bid/ask prices.
Args:
n_snapshots: Number of time steps.
base_price: Starting midprice.
base_spread_bps: Average quoted spread in basis points.
volatility: Per-step return volatility.
seed: Random seed for reproducibility.
Returns:
DataFrame with columns: timestamp, bid, ask, midprice.
"""
rng = np.random.default_rng(seed)
# Random walk midprice
returns = rng.normal(0, volatility, n_snapshots)
midprices = base_price * np.exp(np.cumsum(returns))
# Spread varies around base with some noise
half_spread_frac = (base_spread_bps / 10_000) / 2
spread_noise = rng.uniform(0.5, 1.5, n_snapshots)
bids = midprices * (1 - half_spread_frac * spread_noise)
asks = midprices * (1 + half_spread_frac * spread_noise)
timestamps = pd.date_range("2025-01-01", periods=n_snapshots, freq="100ms")
return pd.DataFrame({
"timestamp": timestamps,
"bid": bids,
"ask": asks,
"midprice": midprices,
})
def generate_synthetic_trades(
orderbook: pd.DataFrame,
trades_per_snapshot: float = 0.3,
informed_fraction: float = 0.15,
seed: int = 123,
) -> pd.DataFrame:
"""Generate synthetic trades against an order book.
Informed trades tend to buy before price rises and sell before drops.
Uninformed trades are random.
Args:
orderbook: Order book snapshots from generate_synthetic_orderbook.
trades_per_snapshot: Average number of trades per snapshot.
informed_fraction: Fraction of trades that are informed.
seed: Random seed for reproducibility.
Returns:
DataFrame with columns: timestamp, price, side, midprice, is_informed.
"""
rng = np.random.default_rng(seed)
trades = []
midprices = orderbook["midprice"].values
future_returns = np.zeros(len(midprices))
lookahead = 50 # look 50 steps ahead for "information"
for i in range(len(midprices) - lookahead):
future_returns[i] = (midprices[i + lookahead] - midprices[i]) / midprices[i]
for i, row in orderbook.iterrows():
if rng.random() > trades_per_snapshot:
continue
is_informed = rng.random() < informed_fraction
if is_informed:
# Informed traders buy before price rises, sell before drops
side = "buy" if future_returns[i] > 0 else "sell"
else:
side = "buy" if rng.random() > 0.5 else "sell"
# Trade executes at ask for buys, bid for sells, with some noise
if side == "buy":
price = row["ask"] * (1 + rng.uniform(0, 0.0001))
else:
price = row["bid"] * (1 - rng.uniform(0, 0.0001))
trades.append({
"timestamp": row["timestamp"],
"price": price,
"side": side,
"midprice": row["midprice"],
"is_informed": is_informed,
})
return pd.DataFrame(trades)
# ── Spread Computation ──────────────────────────────────────────────
def compute_quoted_spread(orderbook: pd.DataFrame) -> pd.Series:
"""Compute quoted spread in basis points for each snapshot.
Args:
orderbook: DataFrame with bid and ask columns.
Returns:
Series of quoted spreads in basis points.
"""
midprice = (orderbook["bid"] + orderbook["ask"]) / 2
return (orderbook["ask"] - orderbook["bid"]) / midprice * 10_000
def compute_effective_spread(trades: pd.DataFrame) -> pd.Series:
"""Compute effective half-spread in basis points for each trade.
The effective spread measures the actual cost paid relative to the
midprice at the time of the trade.
Args:
trades: DataFrame with price, side, and midprice columns.
Returns:
Series of effective half-spreads in basis points.
"""
sign = trades["side"].map({"buy": 1.0, "sell": -1.0})
return sign * (trades["price"] - trades["midprice"]) / trades["midprice"] * 10_000
def compute_realized_spread(
trades: pd.DataFrame,
orderbook: pd.DataFrame,
delay_steps: int = 50,
) -> pd.Series:
"""Compute realized spread: what the market maker actually keeps.
Realized spread = trade_sign * (trade_price - midprice_after_delay).
The difference between effective and realized spread is adverse selection.
Args:
trades: Trade DataFrame with timestamp, price, side columns.
orderbook: Order book DataFrame with timestamp and midprice.
delay_steps: Number of order book snapshots to look ahead.
Returns:
Series of realized half-spreads in basis points.
"""
# Map each trade to the midprice delay_steps later
ob_midprices = orderbook.set_index("timestamp")["midprice"]
ob_timestamps = orderbook["timestamp"].values
realized = []
for _, trade in trades.iterrows():
# Find the orderbook index closest to trade timestamp
idx = np.searchsorted(ob_timestamps, trade["timestamp"])
future_idx = min(idx + delay_steps, len(ob_midprices) - 1)
future_mid = ob_midprices.iloc[future_idx]
sign = 1.0 if trade["side"] == "buy" else -1.0
rs = sign * (trade["price"] - future_mid) / trade["midprice"] * 10_000
realized.append(rs)
return pd.Series(realized, index=trades.index)
def compute_kyle_lambda(
trades: pd.DataFrame,
interval: str = "1s",
) -> dict:
"""Estimate Kyle's lambda from trade data.
Aggregates signed volume and midprice changes over intervals, then
regresses price changes on signed volume.
Args:
trades: Trade DataFrame with timestamp, price, side, midprice.
interval: Time interval for aggregation.
Returns:
Dict with lambda estimate and R-squared.
"""
df = trades.copy()
df["signed_volume"] = df["side"].map({"buy": 1.0, "sell": -1.0})
df = df.set_index("timestamp")
agg = df.resample(interval).agg({
"signed_volume": "sum",
"midprice": "last",
}).dropna()
agg["midprice_change"] = agg["midprice"].diff()
agg = agg.dropna()
if len(agg) < 10:
return {"lambda": 0.0, "r_squared": 0.0, "n_intervals": len(agg)}
X = agg["signed_volume"].values.reshape(-1, 1)
y = agg["midprice_change"].values
X_with_const = np.column_stack([np.ones(len(X)), X])
beta, residuals, _, _ = np.linalg.lstsq(X_with_const, y, rcond=None)
y_hat = X_with_const @ beta
ss_res = np.sum((y - y_hat) ** 2)
ss_tot = np.sum((y - y.mean()) ** 2)
r_sq = 1 - ss_res / ss_tot if ss_tot > 0 else 0.0
return {
"lambda": float(beta[1]),
"r_squared": float(r_sq),
"n_intervals": len(agg),
}
def analyze_spreads(
trades: pd.DataFrame,
orderbook: pd.DataFrame,
delay_steps: int = 50,
) -> SpreadMetrics:
"""Run full spread analysis and return aggregated metrics.
Args:
trades: Trade DataFrame.
orderbook: Order book DataFrame.
delay_steps: Steps for realized spread delay.
Returns:
SpreadMetrics with all computed values.
"""
quoted = compute_quoted_spread(orderbook)
effective = compute_effective_spread(trades)
realized = compute_realized_spread(trades, orderbook, delay_steps)
adverse_selection = effective.mean() - realized.mean()
# Price impact: midprice move in trade direction after delay
signs = trades["side"].map({"buy": 1.0, "sell": -1.0})
ob_midprices = orderbook.set_index("timestamp")["midprice"]
ob_timestamps = orderbook["timestamp"].values
impacts = []
for _, trade in trades.iterrows():
idx = np.searchsorted(ob_timestamps, trade["timestamp"])
future_idx = min(idx + delay_steps, len(ob_midprices) - 1)
future_mid = ob_midprices.iloc[future_idx]
sign = 1.0 if trade["side"] == "buy" else -1.0
impact = sign * (future_mid - trade["midprice"]) / trade["midprice"] * 10_000
impacts.append(impact)
return SpreadMetrics(
quoted_spread_bps=float(quoted.mean()),
effective_spread_bps=float(effective.mean()),
realized_spread_bps=float(realized.mean()),
adverse_selection_bps=float(adverse_selection),
price_impact_bps=float(np.mean(impacts)),
n_trades=len(trades),
)
# ── Visualization ───────────────────────────────────────────────────
def plot_spread_analysis(
orderbook: pd.DataFrame,
trades: pd.DataFrame,
metrics: SpreadMetrics,
output_path: Optional[str] = None,
) -> None:
"""Plot spread analysis results.
Args:
orderbook: Order book DataFrame.
trades: Trade DataFrame.
metrics: Computed spread metrics.
output_path: If provided, save plot to this path instead of showing.
"""
try:
import matplotlib.pyplot as plt
except ImportError:
print("matplotlib not installed, skipping plot.")
return
fig, axes = plt.subplots(3, 1, figsize=(12, 10), sharex=True)
# Panel 1: Price with bid/ask
ax1 = axes[0]
ax1.fill_between(orderbook["timestamp"], orderbook["bid"], orderbook["ask"],
alpha=0.3, color="blue", label="Bid-Ask")
ax1.plot(orderbook["timestamp"], orderbook["midprice"],
color="black", linewidth=0.5, label="Midprice")
buys = trades[trades["side"] == "buy"]
sells = trades[trades["side"] == "sell"]
ax1.scatter(buys["timestamp"], buys["price"], color="green",
s=10, alpha=0.6, label="Buys", zorder=5)
ax1.scatter(sells["timestamp"], sells["price"], color="red",
s=10, alpha=0.6, label="Sells", zorder=5)
ax1.set_ylabel("Price")
ax1.set_title("Order Book and Trades")
ax1.legend(loc="upper left", fontsize=8)
# Panel 2: Quoted spread over time
ax2 = axes[1]
quoted = compute_quoted_spread(orderbook)
ax2.plot(orderbook["timestamp"], quoted, color="purple", linewidth=0.5)
ax2.axhline(y=metrics.quoted_spread_bps, color="red", linestyle="--",
label=f"Mean: {metrics.quoted_spread_bps:.1f} bps")
ax2.set_ylabel("Quoted Spread (bps)")
ax2.set_title("Quoted Spread Over Time")
ax2.legend(fontsize=8)
# Panel 3: Spread decomposition bar chart
ax3 = axes[2]
components = ["Quoted", "Effective", "Realized", "Adverse\nSelection"]
values = [
metrics.quoted_spread_bps,
metrics.effective_spread_bps,
metrics.realized_spread_bps,
metrics.adverse_selection_bps,
]
colors = ["steelblue", "seagreen", "coral", "crimson"]
ax3.bar(components, values, color=colors, edgecolor="black", linewidth=0.5)
for i, v in enumerate(values):
ax3.text(i, v + 0.2, f"{v:.2f}", ha="center", fontsize=9)
ax3.set_ylabel("Basis Points")
ax3.set_title("Spread Decomposition")
plt.tight_layout()
if output_path:
plt.savefig(output_path, dpi=150)
print(f"Plot saved to {output_path}")
else:
plt.show()
# ── Main ────────────────────────────────────────────────────────────
def main() -> None:
"""Run spread analysis from command line."""
parser = argparse.ArgumentParser(
description="Bid-ask spread analysis and decomposition"
)
parser.add_argument(
"--demo", action="store_true",
help="Run with synthetic data (no API key needed)",
)
parser.add_argument(
"--input", type=str, default=None,
help="Path to CSV with trade data (columns: timestamp, price, side, midprice)",
)
parser.add_argument(
"--plot", action="store_true",
help="Show spread analysis plots",
)
parser.add_argument(
"--save-plot", type=str, default=None,
help="Save plot to file instead of displaying",
)
args = parser.parse_args()
if args.demo:
print("Generating synthetic order book and trades...")
orderbook = generate_synthetic_orderbook(
n_snapshots=2000,
base_price=150.0,
base_spread_bps=12.0,
volatility=0.0008,
)
trades = generate_synthetic_trades(
orderbook,
trades_per_snapshot=0.25,
informed_fraction=0.20,
)
print(f" Order book snapshots: {len(orderbook):,}")
print(f" Trades generated: {len(trades):,}")
print()
elif args.input:
trades = pd.read_csv(args.input, parse_dates=["timestamp"])
if "midprice" not in trades.columns:
print("Error: CSV must have a 'midprice' column.")
sys.exit(1)
# Build a minimal orderbook from trade data
orderbook = pd.DataFrame({
"timestamp": trades["timestamp"],
"midprice": trades["midprice"],
"bid": trades["midprice"] * 0.9995,
"ask": trades["midprice"] * 1.0005,
})
print(f"Loaded {len(trades):,} trades from {args.input}")
print()
else:
print("Specify --demo or --input. Use --help for options.")
sys.exit(1)
# Run analysis
print("Computing spread metrics...")
metrics = analyze_spreads(trades, orderbook, delay_steps=50)
print()
print(metrics.summary())
print()
# Kyle's lambda
print("Estimating Kyle's lambda...")
kyle = compute_kyle_lambda(trades, interval="1s")
print(f" Lambda: {kyle['lambda']:.6f}")
print(f" R-squared: {kyle['r_squared']:.4f}")
print(f" N intervals: {kyle['n_intervals']}")
print()
# Informed vs uninformed breakdown (demo only)
if args.demo and "is_informed" in trades.columns:
informed = trades[trades["is_informed"]]
uninformed = trades[~trades["is_informed"]]
print("=== Informed vs Uninformed Breakdown ===")
print(f" Informed trades: {len(informed):,} ({len(informed)/len(trades)*100:.1f}%)")
print(f" Uninformed trades: {len(uninformed):,} ({len(uninformed)/len(trades)*100:.1f}%)")
eff_informed = compute_effective_spread(informed)
eff_uninformed = compute_effective_spread(uninformed)
print(f" Effective spread (informed): {eff_informed.mean():.2f} bps")
print(f" Effective spread (uninformed): {eff_uninformed.mean():.2f} bps")
print()
# Plot
if args.plot or args.save_plot:
plot_spread_analysis(orderbook, trades, metrics, output_path=args.save_plot)
if __name__ == "__main__":
main()