feat: Phase 3 — event-driven market-making simulator + 53 tests
New sim/ module — 7 files + init, replays stored L2/trade data
through a realistic market-making simulation:
sim/engine.py (SimulationEngine):
Event-driven core — processes L2 updates, trades, mark prices
sequentially. Orchestrates queue model, maker quotes, fill sim,
constraints, scenarios. Supports periodic re-quoting and
stale order cancellation.
sim/queue.py (QueueModel):
Price-time FIFO queue per price level. Tracks where maker orders
sit in queue. Simulates order eating by aggressor trades.
fill_probability() — Poisson thinning model for fill odds.
sim/maker.py:
AvellanedaStoikovMaker — stochastic control quoting with
aeta, k, tau parameters. Reservation price based on inventory.
quote() and quote_with_skew() with configurable inventory tilt.
GridMaker — evenly-spaced grid quoting at N levels.
sim/fills.py:
FillSimulator — partial fills, adverse selection probability,
cancel latency (gaussian RTT). FillEvent/CancelEvent tracking.
adverse_selection_intensity() — measures post-fill price moves.
sim/constraints.py:
InventoryConstraint — long/short/net/gross position limits.
FundingConstraint — hourly funding cost estimation.
FeeSchedule — maker/taker fee calculation.
LiquidationRisk — liquidation price and safety distance.
CircuitBreaker — PnL, trade count, toxic rate, slippage trips.
ConstraintManager — unified pre-trade constraint check.
sim/scenario.py:
ScenarioEngine — randomized exchange downtimes, latency spikes,
volatility bursts. State query per sim_time for spread/trade-rate.
sim/reporter.py:
PnLReporter — component-level PnL breakdown:
spread_capture, inventory_pnl, fees, funding, adverse_selection.
SimulationStats — trade counts, fill rates, drawdown, sharpe.
Equity curve tracking and max drawdown computation.
53 new tests across 4 files (all pass):
test_sim_queue.py (12) — order placement, FIFO, fills, cancels
test_sim_maker.py (9) — A-S quotes, inventory skew, grid maker
test_sim_constraints.py (14) — limits, funding, fees, liquidation, breakers
test_sim_reporter.py (12) — PnL components, equity curve, stats
test_sim_engine.py (6) — full engine integration
Total test suite: 134 tests, all passing.
This commit is contained in:
+181
@@ -0,0 +1,181 @@
|
||||
"""
|
||||
Market maker quoting logic.
|
||||
|
||||
Generates bid/ask quotes based on microprice, inventory, volatility,
|
||||
and spread constraints. Uses Avellaneda-Stoikov optimal control framework.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Optional
|
||||
|
||||
|
||||
@dataclass
|
||||
class Quote:
|
||||
"""A pair of maker quotes."""
|
||||
|
||||
bid: float
|
||||
ask: float
|
||||
bid_size: float
|
||||
ask_size: float
|
||||
reservation: float # optimal price given inventory
|
||||
spread_bps: float
|
||||
timestamp: float = 0.0
|
||||
|
||||
|
||||
@dataclass
|
||||
class MakerConfig:
|
||||
"""Configuration for a market-making strategy."""
|
||||
|
||||
gamma: float = 0.1 # risk aversion
|
||||
k: float = 1.5 # orderbook liquidity parameter
|
||||
tau: float = 1.0 # time horizon (hours)
|
||||
min_spread_bps: float = 1.0 # minimum spread in bps
|
||||
max_spread_bps: float = 20.0
|
||||
base_size: float = 0.001 # base quote size
|
||||
max_inventory: float = 0.005
|
||||
skew_factor: float = 0.5 # how aggressively to skew with inventory
|
||||
volatility_window: int = 100
|
||||
|
||||
|
||||
class AvellanedaStoikovMaker:
|
||||
"""Market maker using Avellaneda-Stoikov stochastic control.
|
||||
|
||||
Generates bid/ask quotes that balance spread capture against
|
||||
inventory risk via a reservation price.
|
||||
|
||||
Usage:
|
||||
maker = AvellanedaStoikovMaker(MakerConfig())
|
||||
maker.observe(100000.0) # feed mid prices
|
||||
quote = maker.quote(100000.0, inventory=0.001, elapsed=0.5)
|
||||
"""
|
||||
|
||||
def __init__(self, config: MakerConfig | None = None):
|
||||
self._cfg = config or MakerConfig()
|
||||
self._prices: list[float] = []
|
||||
self._sigma: float = 0.02 # annualized volatility estimate
|
||||
|
||||
def observe(self, mid_price: float):
|
||||
"""Feed a new mid price observation for volatility estimation."""
|
||||
self._prices.append(mid_price)
|
||||
if len(self._prices) > self._cfg.volatility_window:
|
||||
self._prices = self._prices[-self._cfg.volatility_window:]
|
||||
|
||||
if len(self._prices) >= 2:
|
||||
returns = [
|
||||
math.log(self._prices[i] / self._prices[i - 1])
|
||||
for i in range(1, len(self._prices))
|
||||
]
|
||||
if returns:
|
||||
mean = sum(returns) / len(returns)
|
||||
var = sum((r - mean) ** 2 for r in returns) / max(len(returns) - 1, 1)
|
||||
self._sigma = max(math.sqrt(var * 365 * 24), 0.001) # annualize
|
||||
|
||||
def quote(
|
||||
self,
|
||||
mid_price: float,
|
||||
inventory: float,
|
||||
elapsed_hours: float,
|
||||
) -> Quote:
|
||||
"""Generate bid/ask quotes given current state.
|
||||
|
||||
Args:
|
||||
mid_price: current mid price
|
||||
inventory: current signed inventory (+ = long, - = short)
|
||||
elapsed_hours: elapsed time in this session (for T-t decay)
|
||||
"""
|
||||
s = self._sigma
|
||||
gamma = self._cfg.gamma
|
||||
tau_remaining = self._cfg.tau - elapsed_hours
|
||||
tau_remaining = max(tau_remaining, 0.01)
|
||||
|
||||
sigma_sq = s * s
|
||||
r = mid_price - inventory * gamma * sigma_sq * tau_remaining
|
||||
|
||||
optimal_spread = gamma * sigma_sq * tau_remaining + (2.0 / gamma) * math.log(
|
||||
1.0 + gamma / self._cfg.k
|
||||
)
|
||||
optimal_spread = max(optimal_spread, mid_price * self._cfg.min_spread_bps / 10000)
|
||||
optimal_spread = min(optimal_spread, mid_price * self._cfg.max_spread_bps / 10000)
|
||||
|
||||
half = optimal_spread / 2.0
|
||||
bid = r - half
|
||||
ask = r + half
|
||||
|
||||
bid = max(bid, 1.0)
|
||||
ask = max(ask, bid + mid_price * self._cfg.min_spread_bps / 10000)
|
||||
|
||||
spread_bps = (ask - bid) / mid_price * 10000 if mid_price > 0 else 0
|
||||
|
||||
return Quote(
|
||||
bid=round(bid, 2),
|
||||
ask=round(ask, 2),
|
||||
bid_size=self._cfg.base_size,
|
||||
ask_size=self._cfg.base_size,
|
||||
reservation=round(r, 2),
|
||||
spread_bps=round(spread_bps, 2),
|
||||
)
|
||||
|
||||
def quote_with_skew(
|
||||
self,
|
||||
mid_price: float,
|
||||
inventory: float,
|
||||
elapsed_hours: float,
|
||||
target_inventory: float = 0.0,
|
||||
) -> Quote:
|
||||
"""Quote with additional inventory skew toward target."""
|
||||
base = self.quote(mid_price, inventory, elapsed_hours)
|
||||
inv_deviation = (inventory - target_inventory) / max(self._cfg.max_inventory, 0.0001)
|
||||
skew = inv_deviation * self._cfg.skew_factor * base.spread_bps / 10000 * mid_price
|
||||
|
||||
if inventory > target_inventory:
|
||||
return Quote(
|
||||
bid=round(base.bid - skew, 2),
|
||||
ask=round(base.ask - skew, 2),
|
||||
bid_size=base.bid_size * 0.5,
|
||||
ask_size=base.ask_size * 1.5,
|
||||
reservation=base.reservation,
|
||||
spread_bps=base.spread_bps,
|
||||
)
|
||||
else:
|
||||
return Quote(
|
||||
bid=round(base.bid - skew, 2),
|
||||
ask=round(base.ask - skew, 2),
|
||||
bid_size=base.bid_size * 1.5,
|
||||
ask_size=base.ask_size * 0.5,
|
||||
reservation=base.reservation,
|
||||
spread_bps=base.spread_bps,
|
||||
)
|
||||
|
||||
@property
|
||||
def sigma(self) -> float:
|
||||
return self._sigma
|
||||
|
||||
@property
|
||||
def config(self) -> MakerConfig:
|
||||
return self._cfg
|
||||
|
||||
|
||||
class GridMaker:
|
||||
"""Simple grid market maker — places orders at evenly-spaced levels."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
grid_levels: int = 5,
|
||||
spacing_bps: float = 5.0,
|
||||
size_per_level: float = 0.001,
|
||||
):
|
||||
self._levels = grid_levels
|
||||
self._spacing = spacing_bps
|
||||
self._size = size_per_level
|
||||
|
||||
def quotes(self, mid_price: float) -> list[dict]:
|
||||
"""Generate grid quotes around mid."""
|
||||
quotes = []
|
||||
for i in range(1, self._levels + 1):
|
||||
offset = mid_price * self._spacing * i / 10000
|
||||
quotes.append({"side": "bid", "price": round(mid_price - offset, 2), "size": self._size})
|
||||
quotes.append({"side": "ask", "price": round(mid_price + offset, 2), "size": self._size})
|
||||
return quotes
|
||||
Reference in New Issue
Block a user