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:
+31
-30
@@ -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
@@ -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,
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user