Market-making Bot Development
Market-making is the simultaneous placement of buy and sell orders to profit from the spread. A market maker (MM) provides liquidity to the market and earns when both bid and ask are executed. The main enemy of MM is adverse selection: trading against an informed trader who knows the price will move.
Market-making Economics
MM earns from spread capture: if bid = $42,000 and ask = $42,100, and both are executed — profit is $100 (minus fees). But if the price drops to $41,800 during that time, MM bought at $42,000 and now holds a losing position.
A successful MM must:
- Earn enough from spread capture to cover adverse selection losses
- Manage inventory risk (can't accumulate too much position in one direction)
- Adapt spread to current volatility
Basic Quoting Strategy
from decimal import Decimal
import asyncio
class MarketMaker:
def __init__(self, config: MMConfig):
self.pair = config.pair
self.base_spread_bps = Decimal(str(config.spread_bps)) # basis points
self.order_levels = config.levels # number of levels in the book
self.level_size_usd = Decimal(str(config.level_size_usd))
self.max_position_usd = Decimal(str(config.max_position_usd))
async def run(self):
while True:
try:
reference_price = await self.get_reference_price()
inventory = await self.get_inventory()
volatility = await self.estimate_volatility()
quotes = self.calculate_quotes(reference_price, inventory, volatility)
await self.update_orders(quotes)
await asyncio.sleep(1) # update every second
except Exception as e:
logger.error(f"MM cycle error: {e}")
await self.cancel_all()
await asyncio.sleep(5) # pause before retry
def calculate_quotes(self, mid: Decimal, inventory: Decimal, volatility: Decimal) -> Quotes:
# Base spread
half_spread = mid * self.base_spread_bps / Decimal('10000') / 2
# Volatility adjustment: higher volatility — wider spread
vol_multiplier = Decimal('1') + volatility * Decimal('10') # 1x at vol=0, 2x at vol=10%
adjusted_spread = half_spread * vol_multiplier
# Inventory skew: shift quotes to reduce imbalance
skew = self.calculate_inventory_skew(inventory, mid)
best_bid = mid - adjusted_spread + skew
best_ask = mid + adjusted_spread + skew
# Generate multiple levels
bids = []
asks = []
for i in range(self.order_levels):
level_adjustment = adjusted_spread * Decimal(str(i)) * Decimal('0.5')
size = self.level_size_usd / (best_bid - level_adjustment)
bids.append(Quote(price=best_bid - level_adjustment, size=size))
asks.append(Quote(price=best_ask + level_adjustment, size=size))
return Quotes(bids=bids, asks=asks)
def calculate_inventory_skew(self, inventory_usd: Decimal, mid: Decimal) -> Decimal:
"""
Shift quoting based on current inventory.
When accumulating long position — lower bid, raise ask (sell more aggressively).
"""
target = Decimal('0') # target inventory = 0 (neutral)
max_inventory = self.max_position_usd
inventory_ratio = inventory_usd / max_inventory
inventory_ratio = max(Decimal('-1'), min(Decimal('1'), inventory_ratio))
# Skew proportional to inventory: at 100% position — shift by 1 full spread
skew_pct = Decimal('-0.0001') * inventory_ratio # -0.01% * ratio
return mid * skew_pct
Volatility Estimation
def estimate_volatility(self, prices: list[Decimal], window: int = 20) -> Decimal:
"""
Realized volatility over last N periods.
Used for adaptive spread widening.
"""
if len(prices) < window + 1:
return Decimal('0.01') # default if insufficient data
returns = []
for i in range(1, window + 1):
ret = float(prices[-i] / prices[-(i+1)] - 1)
returns.append(ret ** 2)
variance = sum(returns) / len(returns)
vol = Decimal(str(variance ** 0.5))
# Annualized volatility: for 1-minute data
# multiply by sqrt(525600 minutes per year)
return vol * Decimal(str((525600 ** 0.5)))
Order Management
Avoiding Unnecessary Cancel/Replace
Each cancellation and new order incurs a fee and latency. Optimization: only update an order if the price has shifted sufficiently.
async def update_orders(self, new_quotes: Quotes):
current_orders = await self.get_open_orders()
# Compare current orders with new quotes
orders_to_cancel = []
orders_to_place = []
for new_bid in new_quotes.bids:
matching = self.find_matching_order(current_orders.bids, new_bid.price)
if matching:
# Check if update is needed
price_diff = abs(matching.price - new_bid.price) / matching.price
if price_diff > Decimal('0.0005'): # change > 0.05% — update
orders_to_cancel.append(matching.id)
orders_to_place.append(new_bid)
# Otherwise keep existing order
else:
orders_to_place.append(new_bid)
# Batch cancel + place to minimize latency
if orders_to_cancel:
await self.exchange.cancel_orders(orders_to_cancel)
if orders_to_place:
await asyncio.gather(*[
self.exchange.place_order(quote) for quote in orders_to_place
])
Emergency Cancel
In case of sharp market movement or technical error — immediate cancellation of all orders:
async def handle_price_shock(self, current_price: Decimal, reference_price: Decimal):
price_change = abs(current_price - reference_price) / reference_price
if price_change > Decimal('0.02'): # > 2% during update period
logger.warning(f"Price shock detected: {price_change:.2%}. Cancelling all orders.")
await self.exchange.cancel_all_orders(self.pair)
# Pause and monitor
await asyncio.sleep(30)
# Check if price has stabilized
new_price = await self.get_reference_price()
new_change = abs(new_price - reference_price) / reference_price
if new_change < Decimal('0.005'): # < 0.5% — resume
logger.info("Price stabilized, resuming market making")
self.reference_price = new_price
Market-making Risks
Adverse Selection
A large participant knows the price will rise and aggressively buys from MM at bid. MM ends up with long position before the drop.
Detection: if all last N fills are on one side — stop and reassess. Sign of informed trading.
def detect_adverse_selection(self, recent_fills: list[Fill]) -> bool:
if len(recent_fills) < 5:
return False
# All last 5 fills on one side — suspicious
sides = [f.side for f in recent_fills[-5:]]
if len(set(sides)) == 1:
return True
# Fill volume spiked
avg_fill_volume = sum(f.quantity for f in recent_fills[:-3]) / max(len(recent_fills) - 3, 1)
recent_volume = sum(f.quantity for f in recent_fills[-3:]) / 3
if recent_volume > avg_fill_volume * 3:
return True
return False
Naked Position Risk
Hard limit on maximum position — strict circuit breaker:
async def check_position_limits(self):
position = await self.get_net_position() # $ in base asset
if abs(position) > self.max_position_usd:
logger.critical(f"Position limit exceeded: {position} USD")
await self.cancel_all_orders()
# Forced liquidation of excess position
excess = abs(position) - self.max_position_usd
if position > 0: # long excess
await self.market_sell(excess / self.last_price)
else: # short excess
await self.market_buy(abs(excess) / self.last_price)
P&L Calculation
class MMPnLTracker:
def __init__(self):
self.realized_pnl = Decimal('0')
self.fill_history: list[Fill] = []
self.position = Decimal('0') # in base currency
self.avg_cost = Decimal('0')
def on_fill(self, fill: Fill):
self.fill_history.append(fill)
if fill.side == 'buy':
# Update avg cost
new_qty = self.position + fill.quantity
self.avg_cost = (self.position * self.avg_cost + fill.quantity * fill.price) / new_qty
self.position = new_qty
else:
# Realized P&L on sale
pnl = (fill.price - self.avg_cost) * fill.quantity
self.realized_pnl += pnl
self.position -= fill.quantity
def get_unrealized_pnl(self, current_price: Decimal) -> Decimal:
return (current_price - self.avg_cost) * self.position
def get_total_pnl(self, current_price: Decimal) -> Decimal:
return self.realized_pnl + self.get_unrealized_pnl(current_price)
| Strategy Parameter | Typical Values | Impact |
|---|---|---|
| Spread (bps) | 5–50 bps | Higher = fewer fills, more capture per fill |
| Order levels | 3–10 | More = more capital tie-up, deeper book |
| Max position | 5–20% of deposit | Adverse selection risk |
| Volatility multiplier | 1–3x | Protection from gaps |
Development Timeline
- Basic MM bot with single-level quoting: 3–4 weeks
- Multi-level with inventory management: 5–7 weeks
- Production-ready with volatility adaptation and adverse selection detection: 8–12 weeks
- Backtesting framework for strategy: +2–3 weeks







