From f9bed72b1c4c3a2779ef38b1244ce2c1403292b5 Mon Sep 17 00:00:00 2001 From: ramseshk Date: Thu, 6 Aug 2026 08:04:49 +0000 Subject: [PATCH] Proper A-S: side selection via reservation price (not spread formula) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The AS optimal spread formula gives absurd spreads at crypto scale. Real market makers quote at the MARKET spread (best bid/ask) and use AS to decide WHEN to quote based on inventory-adjusted fair value: r = s - q * gamma * sigma^2 * tau If r < best_bid (long-biased) → stop quoting bid If r > best_ask (short-biased) → stop quoting ask If circuit breaker active → pause both sides Decoupled: spread is market-driven, inventory skew is AS-driven. --- live/node.py | 67 +++++++++---------- strategies/as_quoter.py | 141 +++++++++++++++++++--------------------- 2 files changed, 102 insertions(+), 106 deletions(-) diff --git a/live/node.py b/live/node.py index 5ef19c6..2ef858b 100644 --- a/live/node.py +++ b/live/node.py @@ -425,48 +425,49 @@ async def main(): if has_position: continue # Don't replace existing orders - # Avellaneda-Stoikov: proper optimal control (reservation price + spread) + # Avellaneda-Stoikov: side selection via reservation price if name == "Avellaneda-Stoikov": try: - from strategies.as_quoter import ASQuoter - if "_as_quoter" not in dir(): - globals()["_as_quoter"] = ASQuoter( - gamma=0.1, k=1.5, tau=1.0, - min_spread=0.0001, max_inventory=cfg["size"] * 5, - ) - q = ASQuoter - asq = globals()["_as_quoter"] - asq.observe(mid) + from strategies.as_quoter import ASMarketMaker + if "_as_mm" not in dir(): + globals()["_as_mm"] = ASMarketMaker(gamma=0.1, tau=1.0, max_inventory=cfg["size"] * 10) + asmm = globals()["_as_mm"] + asmm.observe(mid) # Get A-S inventory from position tracking as_inv = STRATEGIES[name].get("position", 0.0) - elapsed = (tick * 1.0) % (asq.tau * 3600) / 3600.0 # 1-hour virtual sessions + elapsed = (tick * 1.0) % (asmm.tau * 3600) / 3600.0 - result = asq.quotes(mid, as_inv, elapsed) - if result is None: - continue # Circuit breaker active — skip this tick + selection = asmm.should_quote(mid, bid, ask, as_inv, elapsed) + quote_bid = selection["quote_bid"] + quote_ask = selection["quote_ask"] + r_price = selection.get("reservation", mid) - r_price = result["reservation"] - as_bid = int(result["bid"]) - as_ask = int(result["ask"]) - # Clamp: never cross the market - as_bid = min(as_bid, int(bid)) - as_ask = max(as_ask, int(ask)) + # Quote selected sides at best bid/ask + if quote_bid: + cid_bid = ClientOrderId(str(UUID4())) + try: + client.submit_order(instrument_id=perp.id, client_order_id=cid_bid, order_side=OrderSide.BUY, order_type=OrderType.LIMIT, quantity=Quantity.from_str(str(cfg["size"])), price=Price.from_str(str(int(bid))), time_in_force=TimeInForce.GTC, post_only=True) + active_cloids[name + "_bid"] = str(cid_bid) + active_cloids_times[name + "_bid"] = tick + active_cloids_px[name + "_bid"] = bid + except Exception: + pass + if quote_ask: + cid_ask = ClientOrderId(str(UUID4())) + try: + client.submit_order(instrument_id=perp.id, client_order_id=cid_ask, order_side=OrderSide.SELL, order_type=OrderType.LIMIT, quantity=Quantity.from_str(str(cfg["size"])), price=Price.from_str(str(int(ask))), time_in_force=TimeInForce.GTC, post_only=True) + active_cloids[name + "_ask"] = str(cid_ask) + active_cloids_times[name + "_ask"] = tick + active_cloids_px[name + "_ask"] = ask + except Exception: + pass - cid_bid = ClientOrderId(str(UUID4())) - cid_ask = ClientOrderId(str(UUID4())) - try: - client.submit_order(instrument_id=perp.id, client_order_id=cid_bid, order_side=OrderSide.BUY, order_type=OrderType.LIMIT, quantity=Quantity.from_str(str(cfg["size"])), price=Price.from_str(str(as_bid)), time_in_force=TimeInForce.GTC, post_only=True) - client.submit_order(instrument_id=perp.id, client_order_id=cid_ask, order_side=OrderSide.SELL, order_type=OrderType.LIMIT, quantity=Quantity.from_str(str(cfg["size"])), price=Price.from_str(str(as_ask)), time_in_force=TimeInForce.GTC, post_only=True) - if tick % 60 == 0: - log.info(f"[AS] r={r_price:.1f} σ={asq.sigma*100:.2f}% BID {cfg['size']} @ ${as_bid:,} | ASK {cfg['size']} @ ${as_ask:,} (spread ${as_ask - as_bid:,})") - active_cloids[name] = str(cid_bid) - active_cloids_times[name] = tick - active_cloids_px[name] = as_bid - except Exception: - pass + if tick % 60 == 0 and (quote_bid or quote_ask): + sides = ("BID" if quote_bid else "") + ("|" if quote_bid and quote_ask else "") + ("ASK" if quote_ask else "") + log.info(f"[AS] r={r_price:.1f} σ={selection.get('sigma',0)*100:.2f}% q={as_inv:.6f} {sides}") except Exception: - # Fallback: best bid/ask if module unavailable + # Fallback: best bid/ask both sides cid_bid = ClientOrderId(str(UUID4())) cid_ask = ClientOrderId(str(UUID4())) try: diff --git a/strategies/as_quoter.py b/strategies/as_quoter.py index 81d29aa..1c7c8ab 100644 --- a/strategies/as_quoter.py +++ b/strategies/as_quoter.py @@ -1,117 +1,112 @@ """ -Proper Avellaneda-Stoikov market making for the live node. +Production Avellaneda-Stoikov market making for crypto. -Key formulas (Avellaneda & Stoikov, 2008): - Reservation price: r = s - q * gamma * sigma^2 * tau - Optimal spread: spread = gamma * sigma^2 * tau + (2/gamma) * ln(1 + gamma/k) - Bid = r - spread/2 Ask = r + spread/2 +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. -Where: - s = mid price, q = inventory, gamma = risk aversion - sigma = volatility, tau = remaining session time, k = order intensity + 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 -Production adaptations: - - Rolling volatility estimation (5-min window) - - Circuit breaker: pause quoting when price jump exceeds 3σ - - Inventory bounds: stop quoting on over-exposed side - - Virtual session clock: 1-hour windows since crypto is 24/7 +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 ASQuoter: - """Stateless per-tick quote generator using A-S optimal control.""" +class ASMarketMaker: + """Avellaneda-Stoikov: pick quoting sides based on inventory-adjusted fair value.""" def __init__( self, - gamma: float = 0.1, # Risk aversion — higher = more aggressive inventory redux - k: float = 1.5, # Order flow sensitivity — higher = tighter market - tau: float = 1.0, # Virtual session length (hours, for 24/7 crypto) - min_spread: float = 0.0001, # 1 bp minimum spread - max_inventory: float = 0.001, # Max position before stopping one side - vol_window: int = 300, # Number of price ticks for rolling vol (5 min @ 1s) - cb_mult: float = 3.0, # Circuit breaker multiplier (3σ jump threshold) + 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.k = k self.tau = tau - self.min_spread = min_spread self.max_inventory = max_inventory - self.vol_window = vol_window self.cb_mult = cb_mult - self._mid_prices: deque[float] = deque(maxlen=vol_window) - self._current_sigma: float = 0.02 # fallback: ~32% annualized for crypto - self._session_start: float = 0.0 + 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: - """Feed a new mid-price observation. Updates rolling volatility.""" - self._mid_prices.append(mid) - if len(self._mid_prices) >= 2: - prices = list(self._mid_prices) - returns = [ - (prices[i] - prices[i - 1]) / prices[i - 1] - for i in range(1, len(prices)) - ] + 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.02 - self._current_sigma = sigma + sigma = math.sqrt(var) if var > 0 else 0.01 + self._sigma = max(sigma, 0.001) @property def sigma(self) -> float: - return self._current_sigma + return self._sigma def circuit_breaker(self) -> bool: - """Check if recent price jump exceeds threshold. If true, pause quoting.""" - if len(self._mid_prices) < 5: + if len(self._prices) < 5: return False - recent = list(self._mid_prices)[-5:] + recent = list(self._prices)[-5:] move_pct = abs(recent[-1] - recent[0]) / recent[0] - threshold = self.cb_mult * self._current_sigma * math.sqrt(5) - return move_pct > threshold + return move_pct > self.cb_mult * self._sigma * math.sqrt(5) - def quotes(self, mid: float, inventory: float, t: float) -> dict | None: + # ── Side selection ── + + def should_quote(self, mid: float, best_bid: float, best_ask: float, inventory: float, t: float) -> dict: """ - Generate bid/ask quotes given current state. - - Args: - mid: current mid-price - inventory: current net position (positive = long) - t: elapsed session time in hours (0 to tau) + Determine which sides to quote. Returns: - {"bid": ..., "ask": ..., "reservation": ..., "spread": ...} or None if paused + {"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 None # Pause quoting — price jump in progress + return {"quote_bid": False, "quote_ask": False} - # Reservation price: skew center by inventory risk - tau_remaining = max(self.tau - t, 0.01) - reservation = mid - inventory * self.gamma * (self._current_sigma ** 2) * tau_remaining + # 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 - # Optimal spread: balance risk compensation vs flow capture - try: - log_term = math.log(1.0 + self.gamma / self.k) - except ValueError: - log_term = 0.0 - spread = ( - self.gamma * (self._current_sigma ** 2) * tau_remaining - + (2.0 / max(self.gamma, 0.001)) * log_term - ) - spread = max(spread, self.min_spread) - - half = spread / 2.0 - bid = reservation - half - ask = reservation + half + # 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 { - "bid": max(bid, 1.0), # Never negative/zero - "ask": max(ask, 1.0), + "quote_bid": quote_bid, + "quote_ask": quote_ask, "reservation": reservation, - "spread": spread, + "sigma": self._sigma, }