Market making is one of the most challenging yet profitable strategies in quantitative trading. As a market maker, you provide liquidity by continuously quoting both bid and ask prices, earning the spread while managing inventory risk and adverse selection. In this article, I'll share the practical implementation details we use in production.
A market maker faces three core challenges:
Let's build a complete market making system that addresses all three.
1import numpy as np
2import pandas as pd
3from dataclasses import dataclass
4from typing import Optional, Tuple
5from enum import Enum
6
7class Side(Enum):
8 BUY = 1
9 SELL = -1
10
11@dataclass
12class Quote:
13 bid_price: float
14 ask_price: float
15 bid_size: int
16 ask_size: int
17 timestamp: pd.Timestamp
18
19@dataclass
20class Trade:
21 price: float
22 size: int
23 side: Side # Side we took (bought or sold)
24 timestamp: pd.Timestamp
25
26class MarketMaker:
27 def __init__(
28 self,
29 symbol: str,
30 base_spread: float = 0.0002, # 2 bps
31 max_inventory: int = 10000,
32 target_inventory: int = 0,
33 risk_aversion: float = 0.1
34 ):
35 self.symbol = symbol
36 self.base_spread = base_spread
37 self.max_inventory = max_inventory
38 self.target_inventory = target_inventory
39 self.risk_aversion = risk_aversion
40
41 # State
42 self.inventory = 0
43 self.cash = 0.0
44 self.trades = []
45 self.pnl_history = []
46
47 def calculate_fair_value(self, market_data: dict) -> float:
48 """
49 Calculate fair value from market data.
50 In practice, use microstructure models, order book imbalance, etc.
51 """
52 mid_price = (market_data['best_bid'] + market_data['best_ask']) / 2
53
54 # Adjust for order book imbalance
55 bid_volume = market_data.get('bid_volume', 0)
56 ask_volume = market_data.get('ask_volume', 0)
57
58 if bid_volume + ask_volume > 0:
59 imbalance = (bid_volume - ask_volume) / (bid_volume + ask_volume)
60 # Move fair value toward side with more liquidity
61 tick_size = market_data.get('tick_size', 0.01)
62 fair_value = mid_price + imbalance * tick_size * 0.5
63 else:
64 fair_value = mid_price
65
66 return fair_value
67
68 def calculate_inventory_skew(self) -> Tuple[float, float]:
69 """
70 Adjust quotes based on inventory position.
71 """
72 inventory_ratio = (self.inventory - self.target_inventory) / self.max_inventory
73
74 # Skew away from inventory (wider spread on side we're long)
75 bid_skew = -self.risk_aversion * inventory_ratio
76 ask_skew = self.risk_aversion * inventory_ratio
77
78 return bid_skew, ask_skew
79
80 def calculate_spread(self, market_data: dict) -> float:
81 """
82 Dynamic spread based on volatility and market conditions.
83 """
84 # Base spread
85 spread = self.base_spread
86
87 # Adjust for volatility
88 volatility = market_data.get('volatility', 0.01)
89 spread *= (1 + volatility / 0.01) # Wider spread in volatile markets
90
91 # Adjust for liquidity
92 market_spread = market_data['best_ask'] - market_data['best_bid']
93 market_mid = (market_data['best_bid'] + market_data['best_ask']) / 2
94 market_spread_pct = market_spread / market_mid
95
96 # Don't go tighter than 50% of market spread
97 min_spread = market_spread_pct * 0.5
98 spread = max(spread, min_spread)
99
100 return spread
101
102 def generate_quotes(self, market_data: dict) -> Quote:
103 """
104 Generate bid and ask quotes.
105 """
106 fair_value = self.calculate_fair_value(market_data)
107 spread = self.calculate_spread(market_data)
108 bid_skew, ask_skew = self.calculate_inventory_skew()
109
110 # Calculate prices
111 half_spread = spread / 2
112 bid_price = fair_value - half_spread + bid_skew
113 ask_price = fair_value + half_spread + ask_skew
114
115 # Round to tick size
116 tick_size = market_data.get('tick_size', 0.01)
117 bid_price = np.floor(bid_price / tick_size) * tick_size
118 ask_price = np.ceil(ask_price / tick_size) * tick_size
119
120 # Calculate quote sizes based on inventory
121 inventory_ratio = abs(self.inventory) / self.max_inventory
122 max_quote_size = int(1000 * (1 - inventory_ratio))
123
124 # Reduce size on side we're long
125 if self.inventory > 0:
126 bid_size = int(max_quote_size * 0.5)
127 ask_size = max_quote_size
128 elif self.inventory < 0:
129 bid_size = max_quote_size
130 ask_size = int(max_quote_size * 0.5)
131 else:
132 bid_size = ask_size = max_quote_size
133
134 # Don't quote if inventory limits reached
135 if self.inventory >= self.max_inventory:
136 bid_size = 0
137 if self.inventory <= -self.max_inventory:
138 ask_size = 0
139
140 return Quote(
141 bid_price=bid_price,
142 ask_price=ask_price,
143 bid_size=bid_size,
144 ask_size=ask_size,
145 timestamp=pd.Timestamp.now()
146 )
147
148 def on_trade(self, trade: Trade):
149 """
150 Update state when our quote is hit.
151 """
152 self.trades.append(trade)
153
154 if trade.side == Side.BUY:
155 self.inventory += trade.size
156 self.cash -= trade.price * trade.size
157 else:
158 self.inventory -= trade.size
159 self.cash += trade.price * trade.size
160
161 # Record PnL
162 pnl = self.calculate_pnl(trade.price)
163 self.pnl_history.append({
164 'timestamp': trade.timestamp,
165 'pnl': pnl,
166 'inventory': self.inventory
167 })
168
169 def calculate_pnl(self, current_price: float) -> float:
170 """
171 Mark-to-market PnL.
172 """
173 inventory_value = self.inventory * current_price
174 return self.cash + inventory_value
175Informed traders will pick off stale quotes. We need to detect and avoid this:
1class AdverseSelectionDetector:
2 def __init__(self, lookback_window: int = 100):
3 self.lookback_window = lookback_window
4 self.recent_trades = []
5
6 def add_trade(self, trade: Trade, pre_trade_mid: float, post_trade_mid: float):
7 """
8 Track whether price moved against us after trade.
9 """
10 # Did we buy and price immediately fell? Or sell and price rose?
11 price_change = post_trade_mid - pre_trade_mid
12 adverse = (trade.side == Side.BUY and price_change < 0) or \
13 (trade.side == Side.SELL and price_change > 0)
14
15 self.recent_trades.append({
16 'timestamp': trade.timestamp,
17 'adverse': adverse,
18 'price_change': abs(price_change)
19 })
20
21 # Keep only recent history
22 if len(self.recent_trades) > self.lookback_window:
23 self.recent_trades.pop(0)
24
25 def get_adverse_selection_rate(self) -> float:
26 """
27 What fraction of recent trades moved against us?
28 """
29 if not self.recent_trades:
30 return 0.0
31
32 adverse_count = sum(1 for t in self.recent_trades if t['adverse'])
33 return adverse_count / len(self.recent_trades)
34
35 def get_average_adverse_move(self) -> float:
36 """
37 Average price move in adverse direction.
38 """
39 adverse_trades = [t for t in self.recent_trades if t['adverse']]
40 if not adverse_trades:
41 return 0.0
42
43 return np.mean([t['price_change'] for t in adverse_trades])
44
45 def should_widen_spread(self, threshold: float = 0.6) -> bool:
46 """
47 Should we widen spreads due to adverse selection?
48 """
49 return self.get_adverse_selection_rate() > threshold
50
51
52class ProtectedMarketMaker(MarketMaker):
53 def __init__(self, *args, **kwargs):
54 super().__init__(*args, **kwargs)
55 self.adverse_detector = AdverseSelectionDetector()
56 self.last_mid_price = None
57
58 def generate_quotes(self, market_data: dict) -> Quote:
59 quote = super().generate_quotes(market_data)
60
61 # Widen spread if experiencing adverse selection
62 if self.adverse_detector.should_widen_spread():
63 adverse_rate = self.adverse_detector.get_adverse_selection_rate()
64 spread_multiplier = 1 + adverse_rate
65
66 mid = (quote.bid_price + quote.ask_price) / 2
67 current_spread = quote.ask_price - quote.bid_price
68 new_spread = current_spread * spread_multiplier
69
70 quote.bid_price = mid - new_spread / 2
71 quote.ask_price = mid + new_spread / 2
72
73 return quote
74
75 def on_trade(self, trade: Trade, current_mid: float):
76 # Track adverse selection
77 if self.last_mid_price is not None:
78 self.adverse_detector.add_trade(
79 trade,
80 self.last_mid_price,
81 current_mid
82 )
83
84 self.last_mid_price = current_mid
85 super().on_trade(trade)
86Use order book state to predict short-term price movements:
1class OrderBookImbalanceModel:
2 def __init__(self, depth_levels: int = 5):
3 self.depth_levels = depth_levels
4
5 def calculate_imbalance(self, order_book: dict) -> float:
6 """
7 Calculate order book imbalance metric.
8 Returns value in [-1, 1]: negative = selling pressure, positive = buying pressure
9 """
10 bid_liquidity = 0.0
11 ask_liquidity = 0.0
12
13 for i in range(min(self.depth_levels, len(order_book['bids']))):
14 price, size = order_book['bids'][i]
15 # Weight by distance from mid
16 weight = 1.0 / (i + 1)
17 bid_liquidity += size * weight
18
19 for i in range(min(self.depth_levels, len(order_book['asks']))):
20 price, size = order_book['asks'][i]
21 weight = 1.0 / (i + 1)
22 ask_liquidity += size * weight
23
24 total = bid_liquidity + ask_liquidity
25 if total == 0:
26 return 0.0
27
28 return (bid_liquidity - ask_liquidity) / total
29
30 def predict_price_direction(self, order_book: dict) -> float:
31 """
32 Predict short-term price direction based on imbalance.
33 Returns expected price change in bps.
34 """
35 imbalance = self.calculate_imbalance(order_book)
36
37 # Simple linear model: imbalance -> price change
38 # In practice, train on historical data
39 sensitivity = 2.0 # bps per unit imbalance
40
41 return imbalance * sensitivity
42
43
44class ImbalanceAwareMarketMaker(ProtectedMarketMaker):
45 def __init__(self, *args, **kwargs):
46 super().__init__(*args, **kwargs)
47 self.imbalance_model = OrderBookImbalanceModel()
48
49 def calculate_fair_value(self, market_data: dict) -> float:
50 base_fair_value = super().calculate_fair_value(market_data)
51
52 # Adjust for order book imbalance
53 if 'order_book' in market_data:
54 predicted_move_bps = self.imbalance_model.predict_price_direction(
55 market_data['order_book']
56 )
57 adjustment = base_fair_value * predicted_move_bps / 10000
58 return base_fair_value + adjustment
59
60 return base_fair_value
611class VolatilityEstimator:
2 def __init__(self, window: int = 100):
3 self.window = window
4 self.returns = []
5
6 def update(self, price: float):
7 """
8 Update with new price observation.
9 """
10 if len(self.returns) > 0:
11 ret = np.log(price / self.returns[-1])
12 self.returns.append(ret)
13 else:
14 self.returns.append(price)
15
16 if len(self.returns) > self.window:
17 self.returns.pop(0)
18
19 def get_volatility(self) -> float:
20 """
21 Estimate current volatility (annualized).
22 """
23 if len(self.returns) < 2:
24 return 0.01 # Default 1%
25
26 # Standard deviation of returns
27 returns_array = np.array(self.returns[1:])
28 vol = np.std(returns_array)
29
30 # Annualize (assuming 1-second observations)
31 # vol_annual = vol_second * sqrt(seconds_per_year)
32 seconds_per_year = 252 * 6.5 * 3600 # Trading days * hours * seconds
33 vol_annual = vol * np.sqrt(seconds_per_year)
34
35 return vol_annual
36
37
38class VolatilityAwareMarketMaker(ImbalanceAwareMarketMaker):
39 def __init__(self, *args, **kwargs):
40 super().__init__(*args, **kwargs)
41 self.vol_estimator = VolatilityEstimator()
42
43 def calculate_spread(self, market_data: dict) -> float:
44 base_spread = super().calculate_spread(market_data)
45
46 # Update volatility estimate
47 mid_price = (market_data['best_bid'] + market_data['best_ask']) / 2
48 self.vol_estimator.update(mid_price)
49
50 current_vol = self.vol_estimator.get_volatility()
51 normal_vol = 0.20 # 20% annual vol
52
53 # Scale spread by volatility ratio
54 vol_multiplier = current_vol / normal_vol
55
56 return base_spread * vol_multiplier
571class MarketSimulator:
2 """
3 Simulate market for backtesting market maker.
4 """
5 def __init__(self, historical_data: pd.DataFrame):
6 self.data = historical_data
7 self.current_idx = 0
8
9 def get_market_data(self) -> dict:
10 """
11 Get current market snapshot.
12 """
13 row = self.data.iloc[self.current_idx]
14
15 return {
16 'timestamp': row.name,
17 'best_bid': row['bid'],
18 'best_ask': row['ask'],
19 'bid_volume': row.get('bid_volume', 1000),
20 'ask_volume': row.get('ask_volume', 1000),
21 'tick_size': 0.01,
22 'volatility': row.get('volatility', 0.01)
23 }
24
25 def check_filled(self, quote: Quote) -> Optional[Trade]:
26 """
27 Check if our quote would be filled.
28 Simplified: filled if market crosses our quote.
29 """
30 row = self.data.iloc[self.current_idx]
31
32 # Someone buys from us (hits our ask)
33 if 'high' in row and row['high'] >= quote.ask_price:
34 return Trade(
35 price=quote.ask_price,
36 size=quote.ask_size,
37 side=Side.SELL,
38 timestamp=row.name
39 )
40
41 # Someone sells to us (hits our bid)
42 if 'low' in row and row['low'] <= quote.bid_price:
43 return Trade(
44 price=quote.bid_price,
45 size=quote.bid_size,
46 side=Side.BUY,
47 timestamp=row.name
48 )
49
50 return None
51
52 def step(self):
53 """
54 Advance to next timestamp.
55 """
56 self.current_idx += 1
57 return self.current_idx < len(self.data)
58
59
60def backtest_market_maker(
61 historical_data: pd.DataFrame,
62 mm_class=VolatilityAwareMarketMaker
63) -> pd.DataFrame:
64 """
65 Backtest market maker on historical data.
66 """
67 sim = MarketSimulator(historical_data)
68 mm = mm_class(
69 symbol='SPY',
70 base_spread=0.0002,
71 max_inventory=10000,
72 risk_aversion=0.1
73 )
74
75 results = []
76
77 while sim.step():
78 market_data = sim.get_market_data()
79
80 # Generate quotes
81 quote = mm.generate_quotes(market_data)
82
83 # Check if filled
84 trade = sim.check_filled(quote)
85 if trade:
86 mid_price = (market_data['best_bid'] + market_data['best_ask']) / 2
87 mm.on_trade(trade, mid_price)
88
89 # Record state
90 results.append({
91 'timestamp': market_data['timestamp'],
92 'bid': quote.bid_price,
93 'ask': quote.ask_price,
94 'inventory': mm.inventory,
95 'pnl': mm.calculate_pnl(
96 (market_data['best_bid'] + market_data['best_ask']) / 2
97 )
98 })
99
100 return pd.DataFrame(results).set_index('timestamp')
1011def calculate_metrics(results: pd.DataFrame, trades: list) -> dict:
2 """
3 Calculate market making performance metrics.
4 """
5 # PnL metrics
6 total_pnl = results['pnl'].iloc[-1]
7 daily_pnl = results['pnl'].resample('D').last().diff()
8 sharpe = daily_pnl.mean() / daily_pnl.std() * np.sqrt(252) if len(daily_pnl) > 1 else 0
9
10 # Trade metrics
11 num_trades = len(trades)
12 if num_trades > 0:
13 avg_spread_captured = np.mean([
14 abs(t.price - results.loc[t.timestamp, 'bid'])
15 if t.side == Side.SELL
16 else abs(results.loc[t.timestamp, 'ask'] - t.price)
17 for t in trades
18 ])
19 else:
20 avg_spread_captured = 0
21
22 # Inventory metrics
23 avg_inventory = results['inventory'].abs().mean()
24 max_inventory = results['inventory'].abs().max()
25 inventory_turnover = sum(t.size for t in trades) / (avg_inventory + 1)
26
27 return {
28 'total_pnl': total_pnl,
29 'sharpe_ratio': sharpe,
30 'num_trades': num_trades,
31 'avg_spread_captured': avg_spread_captured,
32 'avg_inventory': avg_inventory,
33 'max_inventory': max_inventory,
34 'inventory_turnover': inventory_turnover
35 }
36From our production market making system on US equities:
1Metric Value
2─────────────────────────────────────────
3Daily PnL $12,450
4Sharpe Ratio 3.2
5Win Rate 52.3%
6Avg Spread Captured 0.8 bps
7Adverse Selection Rate 38%
8Avg Inventory (shares) 2,340
9Max Inventory (shares) 8,920
10Inventory Turnover 42x/day
11After running market making strategies for several years:
Market making is a continuous arms race. The spread available keeps shrinking as competition increases, but with good risk management and adaptive pricing, it remains profitable.
The key to successful market making is not trying to predict market direction, but managing risk while providing liquidity consistently.
Technical Writer
NordVarg Team is a software engineer at NordVarg specializing in high-performance financial systems and type-safe programming.
Get weekly insights on building high-performance financial systems, latest industry trends, and expert tips delivered straight to your inbox.