NV
NordVarg
ServicesTechnologiesIndustriesCase StudiesBlogAboutContact
Get Started

Footer

NV
NordVarg

Software Development & Consulting

GitHubLinkedInTwitter

Services

  • Product Development
  • Quantitative Finance
  • Financial Systems
  • ML & AI

Technologies

  • C++
  • Python
  • Rust
  • OCaml
  • TypeScript
  • React

Company

  • About
  • Case Studies
  • Blog
  • Contact

© 2025 NordVarg. All rights reserved.

November 21, 2024
•
NordVarg Team
•

Market Making Strategies: Inventory Management and Adverse Selection

Quantitative Financemarket-makingtradingquantitative-financepythonrisk-management
10 min read
Share:

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.

The Market Maker's Problem#

A market maker faces three core challenges:

  1. Spread determination: Price competitively while remaining profitable
  2. Inventory management: Avoid accumulating large long or short positions
  3. Adverse selection: Don't get picked off by informed traders

Let's build a complete market making system that addresses all three.

Basic Market Making Framework#

python
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
175

Adverse Selection Protection#

Informed traders will pick off stale quotes. We need to detect and avoid this:

python
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)
86

Order Book Imbalance Model#

Use order book state to predict short-term price movements:

python
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
61

Volatility-Adjusted Spreads#

python
1class 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
57

Complete Simulation#

python
1class 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')
101

Performance Metrics#

python
1def 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    }
36

Real-World Results#

From our production market making system on US equities:

plaintext
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
11

Lessons Learned#

After running market making strategies for several years:

  1. Inventory management is crucial: Large inventories kill returns through adverse selection and overnight risk
  2. Spread must adapt: Static spreads don't work—adjust for volatility, time of day, news
  3. Adverse selection is real: Even with good models, 35-40% of trades move against you immediately
  4. Order book matters: Deep order books = more competition = tighter spreads
  5. Latency is important but not everything: Better models beat faster dumb models
  6. Monitor everything: Track fill rates, adverse selection, inventory by time of day
  7. Have circuit breakers: Max inventory limits, max loss limits, kill switches

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.

Further Reading#

  • Market Making and the Changing Structure of the Foreign Exchange Market by Bjonnes & Rime
  • Market Microstructure in Practice by Lehalle & Laruelle
  • Optimal Market Making by Guéant, Lehalle & Fernandez-Tapia

The key to successful market making is not trying to predict market direction, but managing risk while providing liquidity consistently.

NT

NordVarg Team

Technical Writer

NordVarg Team is a software engineer at NordVarg specializing in high-performance financial systems and type-safe programming.

market-makingtradingquantitative-financepythonrisk-management

Join 1,000+ Engineers

Get weekly insights on building high-performance financial systems, latest industry trends, and expert tips delivered straight to your inbox.

✓Weekly articles
✓Industry insights
✓No spam, ever

Related Posts

Jan 15, 2025•15 min read
Market Making Strategies: Inventory Management and Risk Control
Quantitative Financemarket-makingliquidity-provision
Nov 24, 2024•10 min read
Factor Models in Production: From Research to Live Trading
Quantitative Financefactor-investingquantitative-finance
Nov 11, 2025•7 min read
HFT Cryptocurrency Trading: The 2021 Binance Flash Crash and What We Learned
Quantitative Financehftcryptocurrency

Interested in working together?