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
+31 -30
View File
@@ -425,48 +425,49 @@ async def main():
if has_position: if has_position:
continue # Don't replace existing orders 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": if name == "Avellaneda-Stoikov":
try: try:
from strategies.as_quoter import ASQuoter from strategies.as_quoter import ASMarketMaker
if "_as_quoter" not in dir(): if "_as_mm" not in dir():
globals()["_as_quoter"] = ASQuoter( globals()["_as_mm"] = ASMarketMaker(gamma=0.1, tau=1.0, max_inventory=cfg["size"] * 10)
gamma=0.1, k=1.5, tau=1.0, asmm = globals()["_as_mm"]
min_spread=0.0001, max_inventory=cfg["size"] * 5, asmm.observe(mid)
)
q = ASQuoter
asq = globals()["_as_quoter"]
asq.observe(mid)
# Get A-S inventory from position tracking # Get A-S inventory from position tracking
as_inv = STRATEGIES[name].get("position", 0.0) 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) selection = asmm.should_quote(mid, bid, ask, as_inv, elapsed)
if result is None: quote_bid = selection["quote_bid"]
continue # Circuit breaker active — skip this tick 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())) cid_bid = ClientOrderId(str(UUID4()))
cid_ask = ClientOrderId(str(UUID4()))
try: 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_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)
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) active_cloids[name + "_bid"] = str(cid_bid)
if tick % 60 == 0: active_cloids_times[name + "_bid"] = tick
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_px[name + "_bid"] = bid
active_cloids[name] = str(cid_bid)
active_cloids_times[name] = tick
active_cloids_px[name] = as_bid
except Exception: except Exception:
pass 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: except Exception:
# Fallback: best bid/ask if module unavailable 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 both sides
cid_bid = ClientOrderId(str(UUID4())) cid_bid = ClientOrderId(str(UUID4()))
cid_ask = ClientOrderId(str(UUID4())) cid_ask = ClientOrderId(str(UUID4()))
try: try:
+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): Key insight (missed by most naive implementations):
Reservation price: r = s - q * gamma * sigma^2 * tau The AS formula does NOT tell you what price to quote.
Optimal spread: spread = gamma * sigma^2 * tau + (2/gamma) * ln(1 + gamma/k) The market spread is determined by competition (best bid/ask).
Bid = r - spread/2 Ask = r + spread/2 AS tells you WHEN to quote each side based on your inventory risk.
Where: When you're long → reservation price drops below mid → stop quoting bid
s = mid price, q = inventory, gamma = risk aversion When you're short → reservation price rises above mid → stop quoting ask
sigma = volatility, tau = remaining session time, k = order intensity When flat → quote both sides symmetrically at market best bid/ask
Production adaptations: The AS math you paid attention to:
- Rolling volatility estimation (5-min window) r = s - q * gamma * sigma^2 * tau
- Circuit breaker: pause quoting when price jump exceeds 3σ
- Inventory bounds: stop quoting on over-exposed side Your inventory-adjusted fair value. Compare to market prices.
- Virtual session clock: 1-hour windows since crypto is 24/7 - 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 import math
from collections import deque from collections import deque
class ASQuoter: class ASMarketMaker:
"""Stateless per-tick quote generator using A-S optimal control.""" """Avellaneda-Stoikov: pick quoting sides based on inventory-adjusted fair value."""
def __init__( def __init__(
self, self,
gamma: float = 0.1, # Risk aversion — higher = more aggressive inventory redux gamma: float = 0.1, # Risk aversion
k: float = 1.5, # Order flow sensitivity — higher = tighter market tau: float = 1.0, # Session length (hours)
tau: float = 1.0, # Virtual session length (hours, for 24/7 crypto) max_inventory: float = 0.003, # Max position (3x trade size for BTC)
min_spread: float = 0.0001, # 1 bp minimum spread vol_window: int = 300,
max_inventory: float = 0.001, # Max position before stopping one side cb_mult: float = 3.0,
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)
): ):
self.gamma = gamma self.gamma = gamma
self.k = k
self.tau = tau self.tau = tau
self.min_spread = min_spread
self.max_inventory = max_inventory self.max_inventory = max_inventory
self.vol_window = vol_window
self.cb_mult = cb_mult self.cb_mult = cb_mult
self._mid_prices: deque[float] = deque(maxlen=vol_window) self._prices: deque[float] = deque(maxlen=vol_window)
self._current_sigma: float = 0.02 # fallback: ~32% annualized for crypto self._sigma: float = 0.01 # fallback: 1% return vol
self._session_start: float = 0.0
# ── Vol estimation ──
def observe(self, mid: float) -> None: def observe(self, mid: float) -> None:
"""Feed a new mid-price observation. Updates rolling volatility.""" self._prices.append(mid)
self._mid_prices.append(mid) if len(self._prices) >= 10:
if len(self._mid_prices) >= 2: prices = list(self._prices)
prices = list(self._mid_prices) returns = [(prices[i] - prices[i-1]) / prices[i-1] for i in range(1, len(prices))]
returns = [
(prices[i] - prices[i - 1]) / prices[i - 1]
for i in range(1, len(prices))
]
mu = sum(returns) / len(returns) mu = sum(returns) / len(returns)
var = sum((r - mu) ** 2 for r in returns) / len(returns) var = sum((r - mu) ** 2 for r in returns) / len(returns)
sigma = math.sqrt(var) if var > 0 else 0.02 sigma = math.sqrt(var) if var > 0 else 0.01
self._current_sigma = sigma self._sigma = max(sigma, 0.001)
@property @property
def sigma(self) -> float: def sigma(self) -> float:
return self._current_sigma return self._sigma
def circuit_breaker(self) -> bool: def circuit_breaker(self) -> bool:
"""Check if recent price jump exceeds threshold. If true, pause quoting.""" if len(self._prices) < 5:
if len(self._mid_prices) < 5:
return False return False
recent = list(self._mid_prices)[-5:] recent = list(self._prices)[-5:]
move_pct = abs(recent[-1] - recent[0]) / recent[0] move_pct = abs(recent[-1] - recent[0]) / recent[0]
threshold = self.cb_mult * self._current_sigma * math.sqrt(5) return move_pct > self.cb_mult * self._sigma * math.sqrt(5)
return move_pct > threshold
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. Determine which sides to quote.
Args:
mid: current mid-price
inventory: current net position (positive = long)
t: elapsed session time in hours (0 to tau)
Returns: 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) 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(): 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 # Reservation price (return terms → convert to price)
tau_remaining = max(self.tau - t, 0.01) tau_rem = max(self.tau - t, 0.01)
reservation = mid - inventory * self.gamma * (self._current_sigma ** 2) * tau_remaining # 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 # Side selection: only quote when reservation agrees
try: quote_bid = reservation >= best_bid # We value the asset enough to buy
log_term = math.log(1.0 + self.gamma / self.k) quote_ask = reservation <= best_ask # We'd sell at or above our fair value
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
return { return {
"bid": max(bid, 1.0), # Never negative/zero "quote_bid": quote_bid,
"ask": max(ask, 1.0), "quote_ask": quote_ask,
"reservation": reservation, "reservation": reservation,
"spread": spread, "sigma": self._sigma,
} }