""" Event-driven market-making simulator engine. Replays L2 book updates and trade events sequentially, runs a maker strategy against them, and tracks PnL. Integrates with: - sim/queue.py: queue position tracking - sim/maker.py: quote generation (A-S, Grid) - sim/fills.py: fill simulation (partial, adverse, cancel) - sim/constraints.py: inventory, funding, fees, liquidation, circuit breakers - sim/scenario.py: exchange downtime, latency spikes, vol bursts - sim/reporter.py: PnL component breakdown Usage: engine = SimulationEngine(config=SimConfig(), maker=maker) engine.run(events) print(engine.reporter.stats()) """ from __future__ import annotations import math from dataclasses import dataclass, field from typing import Optional from sim.queue import QueueModel from sim.maker import AvellanedaStoikovMaker, MakerConfig, Quote from sim.fills import FillSimulator, FillModelConfig, FillEvent from sim.constraints import ( ConstraintManager, InventoryConstraint, FeeSchedule, FundingConstraint, LiquidationRisk, CircuitBreaker, ) from sim.scenario import ScenarioEngine, ScenarioConfig from sim.reporter import PnLReporter, SimulationStats, PnLBreakdown @dataclass class SimConfig: """Top-level simulation configuration.""" # Maker config maker: MakerConfig = field(default_factory=MakerConfig) # Fill model fills: FillModelConfig = field(default_factory=FillModelConfig) # Constraints max_inventory: float = 0.005 maker_fee_pct: float = 0.0002 taker_fee_pct: float = 0.0005 funding_rate_hourly: float = 0.0001 initial_equity: float = 10000.0 # Scenario scenario: ScenarioConfig = field(default_factory=ScenarioConfig) # Simulation behavior cancel_after_ms: float = 5000.0 quote_refresh_ms: float = 2000.0 seed: int | None = None @classmethod def from_fee_tier( cls, vip_tier: int = 0, staking_tier: str = "none", maker_rebate_tier: int = 0, **kwargs, ) -> "SimConfig": from config.fee_tiers import get_perp_fees, PERPS_TIERS, STAKING_TIERS maker_fee = get_perp_fees(vip_tier, staking_tier, "maker", maker_rebate_tier) taker_fee = get_perp_fees(vip_tier, staking_tier, "taker", maker_rebate_tier) tier_name = PERPS_TIERS[vip_tier]["name"] staking_name = STAKING_TIERS.get(staking_tier, STAKING_TIERS["none"])["name"] return cls( maker_fee_pct=maker_fee, taker_fee_pct=taker_fee, **kwargs, ) class SimulationEngine: """Event-driven market-making simulator. Processes events sequentially: 1. L2 update → update book, maybe re-quote 2. Trade → check fills, update PnL 3. Timer → funding tick, re-quote, cancel stale orders """ def __init__( self, config: SimConfig | None = None, maker: AvellanedaStoikovMaker | None = None, seed: int | None = None, ): self._cfg = config or SimConfig() seed = seed or self._cfg.seed if maker is None: maker = AvellanedaStoikovMaker(self._cfg.maker) self._maker = maker self._queue = QueueModel() self._fill_sim = FillSimulator(self._cfg.fills, seed=seed) self._constraints = ConstraintManager( inventory=InventoryConstraint(max_long=self._cfg.max_inventory, max_short=self._cfg.max_inventory), fees=FeeSchedule(maker_fee_pct=self._cfg.maker_fee_pct, taker_fee_pct=self._cfg.taker_fee_pct), funding=FundingConstraint(funding_rate_hourly=self._cfg.funding_rate_hourly), ) self._scenario = ScenarioEngine(self._cfg.scenario, seed=seed) self._reporter = PnLReporter(initial_equity=self._cfg.initial_equity) self._sim_time: float = 0.0 self._last_quote_time: float = 0.0 self._last_cancel_time: float = 0.0 self._mid_price: float = 0.0 self._best_bid: float = 0.0 self._best_ask: float = 0.0 self._elapsed_hours: float = 0.0 self._halted: bool = False self._halt_reason: str = "" @property def reporter(self) -> PnLReporter: return self._reporter @property def maker(self) -> AvellanedaStoikovMaker: return self._maker @property def sim_time(self) -> float: return self._sim_time def run(self, events: list[dict]): """Run simulation over a list of events. Each event: {"type": "l2"|"trade"|"mark", "data": {...}, "time": float, "coin": str} """ # Pre-generate scenarios if events: duration = events[-1].get("time", 0.0) - events[0].get("time", 0.0) self._scenario.generate(duration) for event in events: etype = event.get("type", "") data = event.get("data", {}) timestamp = event.get("time", self._sim_time) self._sim_time = timestamp self._elapsed_hours = timestamp / 3600.0 # Check scenarios scenario_state = self._scenario.state(timestamp) if not scenario_state["exchange_up"]: self._reporter.record_downtime(timestamp - self._last_quote_time) continue if etype == "l2": self._process_l2(data) elif etype == "trade": self._process_trade(data) elif etype == "mark": self._process_mark(data) # Periodic actions self._periodic_actions(timestamp) if self._halted: break def _process_l2(self, data: dict): bids = data.get("bids", {}) asks = data.get("asks", {}) if bids: bid_prices = sorted(bids.keys(), reverse=True) self._best_bid = bid_prices[0] if asks: ask_prices = sorted(asks.keys()) self._best_ask = ask_prices[0] self._mid_price = (self._best_bid + self._best_ask) / 2.0 if self._best_bid and self._best_ask else 0 self._reporter.record_spread( (self._best_ask - self._best_bid) / self._mid_price * 10000 if self._mid_price > 0 else 0 ) def _process_trade(self, data: dict): px = float(data.get("px", 0)) sz = float(data.get("sz", 0)) side = data.get("side", "?") if px <= 0 or sz <= 0: return self._mid_price = px aggressor = "buy" if "B" in str(side).upper() or "buy" in str(side).lower() else "sell" fills = self._queue.process_trade( aggressor_side=aggressor, price=px, size=sz, sim_time=self._sim_time, fee_taker=self._cfg.taker_fee_pct, ) for fill in fills: oid = fill["order_id"] fill_px = float(fill["price"]) fill_sz = float(fill["size"]) qp = self._queue.queue_position("bid" if fill.get("_side") == "bid" else "ask", fill_px, oid) fe = self._fill_sim.simulate_fill( order_id=oid, side=fill.get("_side", "bid"), price=fill_px, size=fill_sz, queue_position=qp.position if qp else 0, mid_price=self._mid_price, aggressor_size=sz, timestamp=self._sim_time, fee_rate=self._cfg.maker_fee_pct, ) if fe: self._reporter.record_maker_fill( side=fe.side, price=fe.price, size=fe.size, mid_price=self._mid_price, fee=fe.fee, is_toxic=fe.is_toxic, ) self._check_circuit_breaker() def _process_mark(self, data: dict): mark = float(data.get("mark_px", 0)) if mark > 0: old_mid = self._mid_price self._mid_price = mark if old_mid > 0: self._maker.observe(mark) def _periodic_actions(self, timestamp: float): if self._mid_price <= 0: return # Re-quote if timestamp - self._last_quote_time >= self._cfg.quote_refresh_ms / 1000.0: quote = self._maker.quote( mid_price=self._mid_price, inventory=self._reporter.position, elapsed_hours=self._elapsed_hours, ) self._place_quotes(quote) self._last_quote_time = timestamp # Cancel stale if timestamp - self._last_cancel_time >= self._cfg.cancel_after_ms / 1000.0: for order in self._queue.active_orders(): self._queue.cancel_order(order["oid"], timestamp) self._reporter.record_cancel() self._last_cancel_time = timestamp # Funding tick (hourly) # simplified: funding applied every funding period def _place_quotes(self, quote: Quote): if quote.bid > 0: bid_ok = self._constraints.can_place( side="bid", size=quote.bid_size, current_position=self._reporter.position, mark_price=self._mid_price, ) if bid_ok["allowed"]: self._queue.place_order("bid", quote.bid, quote.bid_size, self._sim_time) if quote.ask > 0: ask_ok = self._constraints.can_place( side="ask", size=quote.ask_size, current_position=self._reporter.position, mark_price=self._mid_price, ) if ask_ok["allowed"]: self._queue.place_order("ask", quote.ask, quote.ask_size, self._sim_time) def _check_circuit_breaker(self): state = { "pnl_pct": round(self._reporter.net_pnl() / self._cfg.initial_equity * 100, 2), "daily_trades": self._reporter.stats().total_trades, "toxic_rate": self._reporter.stats().adverse_rate, "api_errors": 0, } result = self._constraints.breaker.evaluate(state) if result.get("tripped"): self._halted = True self._halt_reason = result.get("reason", "unknown") def stats(self) -> SimulationStats: return self._reporter.stats() def breakdown(self) -> PnLBreakdown: return self._reporter.breakdown()