""" Production Avellaneda-Stoikov market making for crypto. Key insight (missed by most naive implementations): The AS formula does NOT tell you what price to quote. The market spread is determined by competition (best bid/ask). AS tells you WHEN to quote each side based on your inventory risk. When you're long → reservation price drops below mid → stop quoting bid When you're short → reservation price rises above mid → stop quoting ask When flat → quote both sides symmetrically at market best bid/ask The AS math you paid attention to: r = s - q * gamma * sigma^2 * tau Your inventory-adjusted fair value. Compare to market prices. - If r < best_bid: you're overpriced on the buy side → don't bid - If r > best_ask: you're underpriced on the sell side → don't ask This is what Citadel, Jane Street, and every serious MM does. Quote at market, pick sides based on inventory. """ import math from collections import deque class ASMarketMaker: """Avellaneda-Stoikov: pick quoting sides based on inventory-adjusted fair value.""" def __init__( self, gamma: float = 0.1, # Risk aversion tau: float = 1.0, # Session length (hours) max_inventory: float = 0.003, # Max position (3x trade size for BTC) vol_window: int = 300, cb_mult: float = 3.0, ): self.gamma = gamma self.tau = tau self.max_inventory = max_inventory self.cb_mult = cb_mult self._prices: deque[float] = deque(maxlen=vol_window) self._sigma: float = 0.01 # fallback: 1% return vol # ── Vol estimation ── def observe(self, mid: float) -> None: self._prices.append(mid) if len(self._prices) >= 10: prices = list(self._prices) returns = [(prices[i] - prices[i-1]) / prices[i-1] for i in range(1, len(prices))] mu = sum(returns) / len(returns) var = sum((r - mu) ** 2 for r in returns) / len(returns) sigma = math.sqrt(var) if var > 0 else 0.01 self._sigma = max(sigma, 0.001) @property def sigma(self) -> float: return self._sigma def circuit_breaker(self) -> bool: if len(self._prices) < 5: return False recent = list(self._prices)[-5:] move_pct = abs(recent[-1] - recent[0]) / recent[0] return move_pct > self.cb_mult * self._sigma * math.sqrt(5) # ── Side selection ── def should_quote(self, mid: float, best_bid: float, best_ask: float, inventory: float, t: float) -> dict: """ Determine which sides to quote. Returns: {"quote_bid": bool, "quote_ask": bool} Logic: compute reservation price. If it's below best_bid (you're long-biased), stop quoting bid. If it's above best_ask (you're short-biased), stop quoting ask. """ self.observe(mid) # Hard inventory bounds — never exceed max position if abs(inventory) >= self.max_inventory: if inventory > 0: return {"quote_bid": False, "quote_ask": True} # Only sell else: return {"quote_bid": True, "quote_ask": False} # Only buy # Circuit breaker — pause both sides if self.circuit_breaker(): return {"quote_bid": False, "quote_ask": False} # Reservation price (return terms → convert to price) tau_rem = max(self.tau - t, 0.01) # Use notional inventory for meaningful skew q_notional = inventory * mid # Scale gamma for crypto: multiply by mid for effective skew gamma_eff = self.gamma * 500 # tuned for ~$100 allocation scale reservation = mid - q_notional * gamma_eff * (self._sigma ** 2) * tau_rem # Side selection: only quote when reservation agrees quote_bid = reservation >= best_bid # We value the asset enough to buy quote_ask = reservation <= best_ask # We'd sell at or above our fair value return { "quote_bid": quote_bid, "quote_ask": quote_ask, "reservation": reservation, "sigma": self._sigma, }