""" 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 Current adaptation for $100/strategy scale: - gamma_eff = gamma * 500,000 (~$30 skew at max inventory) - sigma floor = 0.001 (0.1% minimal vol) - Sigma squared floor = 0.000001 - Skew: r = mid - q_notional * gamma_eff * sigma^2 * tau - At max position (0.000950 BTC, $60): skew ≈ $30 = 0.05% of mid - Enough to visibly suppress one quoting side """ 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 (scaled internally by 500K) 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._gamma_scale = 500000 # Aggressive for $100 allocation visibility 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. Primary: hard inventory bounds stop quoting over-exposed side. Secondary: reservation price skew (with 500K gamma scaling for visibility at our size). """ self.observe(mid) # Hard inventory bounds — stop quoting the over-exposed side if abs(inventory) >= self.max_inventory: if inventory > 0: return {"quote_bid": False, "quote_ask": True, "reservation": mid, "sigma": self._sigma} else: return {"quote_bid": True, "quote_ask": False, "reservation": mid, "sigma": self._sigma} # Circuit breaker if self.circuit_breaker(): return {"quote_bid": False, "quote_ask": False, "reservation": mid, "sigma": self._sigma} # Reservation price with aggressive gamma scaling q_notional = inventory * mid gamma_eff = self.gamma * self._gamma_scale tau_rem = max(self.tau - t, 0.01) sigma_sq = max(self._sigma ** 2, 0.000001) # floor: 0.1% vol squared reservation = mid - q_notional * gamma_eff * sigma_sq * tau_rem # At $60 notional: skew ≈ $30 → 0.05% of mid — small but directional quote_bid = reservation >= best_bid or abs(inventory) < self.max_inventory * 0.1 quote_ask = reservation <= best_ask or abs(inventory) < self.max_inventory * 0.1 return { "quote_bid": quote_bid, "quote_ask": quote_ask, "reservation": reservation, "sigma": self._sigma, }