Proper A-S: side selection via reservation price (not spread formula)

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.
This commit is contained in:
ramseshk
2026-08-06 08:04:49 +00:00
parent a5de7d526f
commit f9bed72b1c
2 changed files with 102 additions and 106 deletions
+68 -73
View File
@@ -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,
}