diff --git a/sim/__init__.py b/sim/__init__.py new file mode 100644 index 0000000..12051e6 --- /dev/null +++ b/sim/__init__.py @@ -0,0 +1,55 @@ +""" +Event-driven market-making simulator. + +Replays L2 and trade data, models queue position, fill probability, +adverse selection, and generates component-level PnL breakdowns. +""" + +from sim.engine import SimulationEngine, SimConfig +from sim.queue import QueueModel, QueuePosition, QueueLevel, fill_probability +from sim.maker import ( + AvellanedaStoikovMaker, + GridMaker, + MakerConfig, + Quote, +) +from sim.fills import ( + FillSimulator, + FillModelConfig, + FillEvent, + CancelEvent, + adverse_selection_intensity, +) +from sim.constraints import ( + ConstraintManager, + InventoryConstraint, + FeeSchedule, + FundingConstraint, + LiquidationRisk, + CircuitBreaker, +) +from sim.scenario import ( + ScenarioEngine, + ScenarioConfig, + ExchangeDowntime, + LatencySpike, + VolatilityBurst, +) +from sim.reporter import ( + PnLReporter, + PnLBreakdown, + SimulationStats, +) + +__all__ = [ + "SimulationEngine", "SimConfig", + "QueueModel", "QueuePosition", "QueueLevel", "fill_probability", + "AvellanedaStoikovMaker", "GridMaker", "MakerConfig", "Quote", + "FillSimulator", "FillModelConfig", "FillEvent", "CancelEvent", + "adverse_selection_intensity", + "ConstraintManager", "InventoryConstraint", "FeeSchedule", + "FundingConstraint", "LiquidationRisk", "CircuitBreaker", + "ScenarioEngine", "ScenarioConfig", "ExchangeDowntime", + "LatencySpike", "VolatilityBurst", + "PnLReporter", "PnLBreakdown", "SimulationStats", +] diff --git a/sim/constraints.py b/sim/constraints.py new file mode 100644 index 0000000..3debd4d --- /dev/null +++ b/sim/constraints.py @@ -0,0 +1,183 @@ +""" +Simulation constraints: inventory limits, funding costs, fees, liquidation risk. + +Enforces realistic exchange and risk constraints on simulated trading. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Optional + + +@dataclass +class InventoryConstraint: + """Position and inventory limits.""" + + max_long: float = 0.005 # max long position (notional or units) + max_short: float = 0.005 # max short position + max_net_exposure: float = 0.005 + max_gross_exposure: float = 0.01 + + def check(self, current_long: float, current_short: float) -> dict: + """Check whether adding size violates limits.""" + net = current_long - current_short + gross = current_long + current_short + + return { + "long_ok": current_long <= self.max_long, + "short_ok": current_short <= self.max_short, + "net_ok": abs(net) <= self.max_net_exposure, + "gross_ok": gross <= self.max_gross_exposure, + "long_headroom": max(0.0, self.max_long - current_long), + "short_headroom": max(0.0, self.max_short - current_short), + } + + +@dataclass +class FundingConstraint: + """Funding rate cost estimation.""" + + funding_rate_hourly: float = 0.0001 # hourly funding rate (positive = shorts earn) + predict_funding: bool = False + + def cost_per_hour(self, position: float, mark_price: float) -> float: + """Cost (negative = earn) of holding position for one hour.""" + return position * mark_price * self.funding_rate_hourly + + def annualized_cost_pct(self, funding_rate: float) -> float: + """Annualize an hourly funding rate to percentage.""" + return funding_rate * 365 * 24 * 100 + + +@dataclass +class FeeSchedule: + """Exchange fee structure.""" + + maker_fee_pct: float = 0.0002 # 2 bps maker + taker_fee_pct: float = 0.0005 # 5 bps taker + + def maker_fee(self, notional: float) -> float: + return notional * self.maker_fee_pct + + def taker_fee(self, notional: float) -> float: + return notional * self.taker_fee_pct + + +@dataclass +class LiquidationRisk: + """Liquidation risk monitor.""" + + maintenance_margin_pct: float = 0.03 # 3% maintenance + initial_margin_pct: float = 0.05 # 5% initial + mark_price: float = 0.0 + + def liquidation_price( + self, + entry_price: float, + size: float, + position_side: str, # "long" or "short" + wallet_balance: float, + ) -> float: + """Compute liquidation price for a position.""" + if size <= 0: + return 0.0 + + margin = entry_price * size * self.initial_margin_pct + mtn = entry_price * size * self.maintenance_margin_pct + + if position_side == "long": + return entry_price * (1 - (wallet_balance - margin) / (size * entry_price) + mtn / (size * entry_price)) + else: + return entry_price * (1 + (wallet_balance - margin) / (size * entry_price) - mtn / (size * entry_price)) + + def distance_to_liquidation_pct( + self, + mark_price: float, + liquidation_price: float, + position_side: str, + ) -> float: + """How far away is liquidation, as a percentage.""" + if liquidation_price <= 0: + return float("inf") + + if position_side == "long": + return (mark_price - liquidation_price) / mark_price * 100 if mark_price > 0 else 0 + else: + return (liquidation_price - mark_price) / mark_price * 100 if mark_price > 0 else 0 + + def is_safe(self, distance_pct: float, threshold_pct: float = 2.0) -> bool: + return distance_pct > threshold_pct + + +@dataclass +class CircuitBreaker: + """Circuit breaker for stopping trading under adverse conditions.""" + + max_drawdown_pct: float = -5.0 # stop if PnL < -5% + max_daily_trades: int = 500 + max_slippage_bps: float = 50.0 # stop if avg slippage > 50bps + max_toxic_rate: float = 0.4 # stop if >40% fills are toxic + max_api_errors: int = 10 + cooldown_seconds: float = 300.0 # 5 min cooldown after trip + + def evaluate(self, state: dict) -> dict: + """Check all breakers. Returns reason if tripped, None if ok.""" + if state.get("pnl_pct", 0) < self.max_drawdown_pct: + return {"tripped": True, "reason": f"drawdown {state['pnl_pct']:.1f}% < {self.max_drawdown_pct}%"} + + if state.get("daily_trades", 0) > self.max_daily_trades: + return {"tripped": True, "reason": f"trades {state['daily_trades']} > {self.max_daily_trades}"} + + if state.get("avg_slippage_bps", 0) > self.max_slippage_bps: + return {"tripped": True, "reason": f"slippage {state['avg_slippage_bps']:.1f}bps > {self.max_slippage_bps}bps"} + + if state.get("toxic_rate", 0) > self.max_toxic_rate: + return {"tripped": True, "reason": f"toxic rate {state['toxic_rate']:.1%} > {self.max_toxic_rate:.1%}"} + + if state.get("api_errors", 0) > self.max_api_errors: + return {"tripped": True, "reason": f"api errors {state['api_errors']} > {self.max_api_errors}"} + + return {"tripped": False} + + +class ConstraintManager: + """Central constraint checker combining all limits.""" + + def __init__( + self, + inventory: InventoryConstraint | None = None, + fees: FeeSchedule | None = None, + funding: FundingConstraint | None = None, + liquidation: LiquidationRisk | None = None, + breaker: CircuitBreaker | None = None, + ): + self.inventory = inventory or InventoryConstraint() + self.fees = fees or FeeSchedule() + self.funding = funding or FundingConstraint() + self.liquidation = liquidation or LiquidationRisk() + self.breaker = breaker or CircuitBreaker() + + def can_place( + self, + side: str, + size: float, + current_position: float, + mark_price: float, + ) -> dict: + """Check whether we can place an order of given side and size.""" + new_pos = current_position + (size if side == "bid" else -size) + + limits = self.inventory.check( + max(0.0, new_pos) if side == "bid" else max(0.0, current_position), + max(0.0, -new_pos) if side == "ask" else max(0.0, -current_position), + ) + + fee_est = self.fees.maker_fee(size * mark_price) + + return { + "allowed": limits["long_ok"] and limits["short_ok"], + "new_position": new_pos, + "fee_estimate": round(fee_est, 6), + "limits": limits, + } diff --git a/sim/engine.py b/sim/engine.py new file mode 100644 index 0000000..0b52fe9 --- /dev/null +++ b/sim/engine.py @@ -0,0 +1,291 @@ +""" +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 # cancel and re-quote every N ms + quote_refresh_ms: float = 2000.0 # refresh quotes every N ms + seed: int | None = None + + +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() diff --git a/sim/fills.py b/sim/fills.py new file mode 100644 index 0000000..e189a89 --- /dev/null +++ b/sim/fills.py @@ -0,0 +1,182 @@ +""" +Fill simulation: partial fills, cancel latency, adverse selection. + +Models realistic fill behavior for maker orders: +- Partial fills (not all-or-nothing) +- Cancel latency (cancel arrives after fill) +- Adverse selection (getting filled right before adverse price move) +""" + +from __future__ import annotations + +import math +import random +from dataclasses import dataclass, field +from typing import Optional + + +@dataclass +class FillEvent: + """A fill (or partial fill) of a maker order.""" + + order_id: str + side: str + price: float + size: float + fee: float + pnl_immediate: float # PnL if closed instantly at mid + is_toxic: bool # was this fill followed by adverse price move? + timestamp: float + aggressor_side: str = "" + + +@dataclass +class CancelEvent: + order_id: str + requested_time: float + executed_time: float + filled_before_cancel: float + latency_ms: float + + +@dataclass +class FillModelConfig: + """Parameters for fill simulation.""" + + partial_fill_prob: float = 0.3 # probability of partial (not full) fill + min_fill_ratio: float = 0.25 # min fraction of order filled + cancel_latency_ms: float = 50.0 # typical cancel RTT + cancel_latency_std_ms: float = 20.0 + adverse_selection_prob: float = 0.15 # prob a fill is adverse + adverse_move_bps: float = 3.0 # bps adverse move after toxic fill + queue_priority_decay: float = 0.02 # per-event reduction in fill prob if not front + + +class FillSimulator: + """Simulates fill behavior for maker orders in the queue model.""" + + def __init__(self, config: FillModelConfig | None = None, seed: int | None = None): + self._cfg = config or FillModelConfig() + self._rng = random.Random(seed) + self._events: list[FillEvent | CancelEvent] = [] + + def simulate_fill( + self, + order_id: str, + side: str, + price: float, + size: float, + queue_position: int, + mid_price: float, + aggressor_size: float, + timestamp: float, + fee_rate: float = 0.0002, + ) -> Optional[FillEvent]: + """Simulate whether this trade event fills our order. + + Returns FillEvent if filled, None if our order survives. + """ + if queue_position > 0: + # Not at front: low probability of fill from agg size + prob = min(aggressor_size / (size * 2), 0.1) + if self._rng.random() > prob: + return None + + # At front or lucky: may get partial fill + is_partial = self._rng.random() < self._cfg.partial_fill_prob + fill_ratio = self._rng.uniform(self._cfg.min_fill_ratio, 1.0) if is_partial else 1.0 + fill_size = round(size * fill_ratio, 8) + + # Fee + fee = fill_size * price * fee_rate + + # Immediate PnL estimate + aggressive = "buy" if side == "ask" else "sell" + if side == "bid": + pnl_immediate = fill_size * (mid_price - price) - fee + else: + pnl_immediate = fill_size * (price - mid_price) - fee + + # Adverse selection check + is_toxic = self._rng.random() < self._cfg.adverse_selection_prob + + event = FillEvent( + order_id=order_id, + side=side, + price=price, + size=fill_size, + fee=round(fee, 6), + pnl_immediate=round(pnl_immediate, 6), + is_toxic=is_toxic, + timestamp=timestamp, + aggressor_side=aggressive, + ) + self._events.append(event) + return event + + def simulate_cancel( + self, + order_id: str, + timestamp: float, + ) -> CancelEvent: + """Simulate cancel with random latency.""" + lat = max(1.0, self._rng.gauss(self._cfg.cancel_latency_ms, self._cfg.cancel_latency_std_ms)) + event = CancelEvent( + order_id=order_id, + requested_time=timestamp, + executed_time=timestamp + lat / 1000.0, + filled_before_cancel=0.0, + latency_ms=round(lat, 2), + ) + self._events.append(event) + return event + + @property + def events(self) -> list: + return self._events + + def fills(self) -> list[FillEvent]: + return [e for e in self._events if isinstance(e, FillEvent)] + + def cancels(self) -> list[CancelEvent]: + return [e for e in self._events if isinstance(e, CancelEvent)] + + +# ── Adverse selection estimator ──────────────────────────── + +def adverse_selection_intensity( + fills: list[FillEvent], + future_mids: list[float], + horizon_events: int = 5, +) -> dict: + """Compute how often fills are followed by adverse price moves. + + A fill is adverse if mid price moves against the maker within + horizon_events subsequent trades. + """ + if not fills or len(future_mids) < horizon_events: + return {"adverse_rate": 0, "mean_cost_bps": 0, "n_fills": len(fills)} + + adverse_count = 0 + adverse_costs = [] + n = len(future_mids) + + for i, fill in enumerate(fills): + future_idx = min(i + horizon_events, n - 1) + mid_now = future_mids[i] if i < n else 0 + mid_future = future_mids[future_idx] + if mid_now <= 0 or mid_future <= 0: + continue + + move_bps = (mid_future - mid_now) / mid_now * 10000 + + if (fill.side == "bid" and move_bps < 0) or (fill.side == "ask" and move_bps > 0): + adverse_count += 1 + adverse_costs.append(abs(move_bps)) + + return { + "adverse_rate": round(adverse_count / len(fills), 4) if fills else 0, + "mean_cost_bps": round(sum(adverse_costs) / max(len(adverse_costs), 1), 2), + "total_cost_bps": round(sum(adverse_costs), 2), + "n_fills": len(fills), + } diff --git a/sim/maker.py b/sim/maker.py new file mode 100644 index 0000000..488a8fc --- /dev/null +++ b/sim/maker.py @@ -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 diff --git a/sim/queue.py b/sim/queue.py new file mode 100644 index 0000000..a0e5baf --- /dev/null +++ b/sim/queue.py @@ -0,0 +1,262 @@ +""" +Order book queue position model. + +Simulates where a limit order sits in the price-time FIFO queue +and computes fill probability, expected queue time, and greeks +for queue position management. +""" + +from __future__ import annotations + +import math +from collections import defaultdict +from dataclasses import dataclass + + +@dataclass +class QueuePosition: + """Position of an order in the queue at a given price level.""" + + price: float + side: str # "bid" or "ask" + size: float # order size + position: int # position in queue (0 = front) + total_queue: int # total orders ahead at this price + total_size: float # total size ahead at this price (excluding our order) + arrival_time: float # simulation time order was placed + + @property + def is_front(self) -> bool: + return self.position == 0 + + @property + def queue_ratio(self) -> float: + """Fraction of total size we represent at this level.""" + total = self.total_size + self.size + return self.size / total if total > 0 else 1.0 + + +@dataclass +class QueueLevel: + """Aggregated data for a single price level in the book.""" + + price: float + total_size: float + order_count: int + oldest_age: float # simulation time of oldest order + + +class QueueModel: + """Manages queue positions for maker orders on both sides. + + Tracks where our orders sit in the FIFO queue at each price level. + Simulates queue progression as trades eat through levels. + + Usage: + qm = QueueModel() + qm.place_order("bid", 50000.0, 0.01, sim_time=100.0) + qm.process_trade("bid", 50000.0, 0.005, sim_time=100.5) + status = qm.order_status("bid", 50000.0) + """ + + def __init__(self): + self._bids: dict[float, list[dict]] = defaultdict(list) # price → [{size, time, ours}] + self._asks: dict[float, list[dict]] = defaultdict(list) + self._our_orders: dict[str, dict] = {} # order_id → {price, side, size, time, filled} + + def place_order( + self, + side: str, + price: float, + size: float, + sim_time: float, + order_id: str | None = None, + ) -> str: + """Place a new maker order. Returns order_id.""" + oid = order_id or f"qt{abs(hash(str(sim_time) + side + str(price))):08x}" + book = self._bids if side == "bid" else self._asks + + entry = {"size": size, "time": sim_time, "ours": True, "oid": oid} + book[price].append(entry) + + self._our_orders[oid] = { + "oid": oid, + "price": price, + "side": side, + "size": size, + "time": sim_time, + "filled": 0.0, + "status": "active", + } + return oid + + def cancel_order(self, order_id: str, sim_time: float) -> float: + """Cancel an order. Returns filled amount before cancel.""" + order = self._our_orders.get(order_id) + if not order: + return 0.0 + + book = self._bids if order["side"] == "bid" else self._asks + price = order["price"] + size = order["size"] + + # Remove from queue + if price in book: + book[price] = [o for o in book[price] if o.get("oid") != order_id] + + order["status"] = "cancelled" + return order["filled"] + + def process_trade( + self, + aggressor_side: str, # "buy" = market buy (hits asks), "sell" = market sell (hits bids) + price: float, + size: float, + sim_time: float, + fee_taker: float = 0.0005, + ) -> list[dict]: + """Process an aggressor trade. Returns list of our fill events. + + A buy trade eats through asks (price ≤ trade price). + A sell trade eats through bids (price ≥ trade price). + """ + fills = [] + remaining = size + + if aggressor_side == "buy": + target_book = self._asks + prices = sorted(target_book.keys()) # lowest ask first + else: + target_book = self._bids + prices = sorted(target_book.keys(), reverse=True) # highest bid first + + for px in prices: + if aggressor_side == "buy" and px > price: + break + if aggressor_side == "sell" and px < price: + break + + orders = target_book[px] + while orders and remaining > 0: + order = orders[0] + eat = min(order["size"], remaining) + order["size"] -= eat + remaining -= eat + + if order.get("ours"): + oid = order["oid"] + if oid in self._our_orders: + self._our_orders[oid]["filled"] += eat + fills.append({ + "order_id": oid, + "price": px, + "size": eat, + "side": order.get("_side", ""), + "time": sim_time, + "fee": round(eat * px * fee_taker, 6), + "aggressor": aggressor_side, + }) + + if order["size"] <= 1e-12: + orders.pop(0) + + if not orders: + del target_book[px] + + if remaining <= 0: + break + + # Mark fully filled orders + for oid, order in self._our_orders.items(): + if abs(order["filled"] - order["size"]) < 1e-10 and order["status"] == "active": + order["status"] = "filled" + + return fills + + def order_status(self, order_id: str) -> dict | None: + """Get current status of a placed order.""" + return self._our_orders.get(order_id) + + def queue_position(self, side: str, price: float, order_id: str) -> QueuePosition | None: + """Get queue position info for a specific order.""" + order = self._our_orders.get(order_id) + if not order: + return None + + book = self._bids if side == "bid" else self._asks + orders = book.get(price, []) + pos = 0 + ahead_size = 0.0 + found = False + + for o in orders: + if o.get("oid") == order_id: + found = True + break + pos += 1 + ahead_size += o["size"] + + if not found: + return None + + return QueuePosition( + price=price, + side=side, + size=order["size"] - order["filled"], + position=pos, + total_queue=len(orders), + total_size=ahead_size, + arrival_time=order["time"], + ) + + def top_of_book(self) -> dict: + """Get best bid/ask with total sizes.""" + best_bid = max(self._bids) if self._bids else 0 + best_ask = min(self._asks) if self._asks else 0 + bid_size = sum(o["size"] for o in self._bids.get(best_bid, [])) + ask_size = sum(o["size"] for o in self._asks.get(best_ask, [])) + return { + "best_bid": best_bid, + "best_ask": best_ask, + "bid_size": bid_size, + "ask_size": ask_size, + "spread": best_ask - best_bid if best_bid and best_ask else 0, + } + + def active_orders(self) -> list[dict]: + return [o for o in self._our_orders.values() if o["status"] == "active"] + + +# ── Fill probability estimation ───────────────────────────── + +def fill_probability( + queue_pos: int, + total_queue_depth: float, + order_size: float, + arrival_rate: float, # trades/sec at this level + time_horizon: float, # seconds +) -> dict: + """Estimate fill probability for an order at given queue position. + + Uses a Poisson thinning model: each arriving trade has probability + of reaching this queue position. + + Returns prob and expected fill time. + """ + if queue_pos == 0: + prob = 1.0 - math.exp(-arrival_rate * time_horizon) + expected_time = 1.0 / arrival_rate if arrival_rate > 0 else float("inf") + else: + # Probability trade reaches position k: depends on trade sizes vs queue + lam = arrival_rate * time_horizon + depth_at_level = total_queue_depth / max(queue_pos, 1) + thin_factor = max(0.0, 1.0 - depth_at_level / (order_size * 10)) # heuristic + prob = (1.0 - math.exp(-lam)) * thin_factor + expected_time = time_horizon / max(prob, 1e-6) + + return { + "fill_probability": round(prob, 6), + "expected_fill_time_s": round(min(expected_time, 86400), 2), + "queue_position": queue_pos, + "time_horizon_s": time_horizon, + } diff --git a/sim/reporter.py b/sim/reporter.py new file mode 100644 index 0000000..83f0b86 --- /dev/null +++ b/sim/reporter.py @@ -0,0 +1,234 @@ +""" +Simulation PnL reporter. + +Breaks down trading PnL into components: + - Spread capture (earned spread on maker fills) + - Inventory PnL (mark-to-market on held position) + - Fee cost (maker + taker fees) + - Funding cost + - Adverse selection cost + - Slippage cost +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Optional + + +@dataclass +class PnLBreakdown: + """Component-level PnL breakdown.""" + + spread_capture: float = 0.0 # positive = we earned spread + inventory_pnl: float = 0.0 # MTM on held positions + maker_fees: float = 0.0 # fees paid as maker (negative) + taker_fees: float = 0.0 # fees paid as taker (negative) + funding_pnl: float = 0.0 # funding received (positive) or paid (negative) + adverse_selection_cost: float = 0.0 # loss from fills before adverse moves + gross_pnl: float = 0.0 # before fees + net_pnl: float = 0.0 # after fees + + @property + def total_fees(self) -> float: + return self.maker_fees + self.taker_fees + + @property + def total_net(self) -> float: + return self.net_pnl + + +@dataclass +class SimulationStats: + """Per-run aggregate statistics.""" + + total_trades: int = 0 + bid_fills: int = 0 + ask_fills: int = 0 + cancels: int = 0 + partial_fills: int = 0 + toxic_fills: int = 0 + adverse_rate: float = 0.0 + avg_fill_size: float = 0.0 + avg_spread_bps: float = 0.0 + max_inventory: float = 0.0 + max_drawdown: float = 0.0 + sharpe: float = 0.0 + sortino: float = 0.0 + uptime_pct: float = 100.0 + avg_latency_ms: float = 0.0 + pnl: PnLBreakdown = field(default_factory=PnLBreakdown) + + +class PnLReporter: + """Tracks and reports PnL components during simulation.""" + + def __init__(self, initial_equity: float = 10000.0): + self._initial_equity = initial_equity + self._equity = initial_equity + self._position: float = 0.0 + self._entry_price: float = 0.0 + self._equity_curve: list[dict] = [{"t": 0.0, "v": initial_equity}] + + # Component accumulators + self._spread_capture = 0.0 + self._inv_pnl = 0.0 + self._maker_fees = 0.0 + self._taker_fees = 0.0 + self._funding_pnl = 0.0 + self._adverse_cost = 0.0 + self._gross_pnl = 0.0 + + # Counters + self._bid_fills = 0 + self._ask_fills = 0 + self._cancels = 0 + self._partials = 0 + self._toxic_fills = 0 + self._total_trades = 0 + + self._max_inventory = 0.0 + self._peak_equity = initial_equity + self._max_dd = 0.0 + self._latencies: list[float] = [] + self._spreads: list[float] = [] + self._downtime_total = 0.0 + self._sim_duration = 0.0 + + def record_maker_fill( + self, + side: str, + price: float, + size: float, + mid_price: float, + fee: float, + is_toxic: bool = False, + latency_ms: float = 0, + ): + """Record a maker fill event.""" + if side == "bid": + spread_cap = size * (mid_price - price) + self._position += size + else: + spread_cap = size * (price - mid_price) + self._position -= size + + self._spread_capture += spread_cap + self._maker_fees -= fee + self._gross_pnl += spread_cap - fee + self._equity += spread_cap - fee + self._total_trades += 1 + + if side == "bid": + self._bid_fills += 1 + else: + self._ask_fills += 1 + + if is_toxic: + self._toxic_fills += 1 + self._adverse_cost += spread_cap * 0.5 # rough estimate + + if abs(self._position) > self._max_inventory: + self._max_inventory = abs(self._position) + + self._update_drawdown() + self._equity_curve.append({"t": self._equity_curve[-1]["t"], "v": self._equity}) + if latency_ms > 0: + self._latencies.append(latency_ms) + + def record_cancel(self): + self._cancels += 1 + + def record_equity_update(self, sim_time: float, mid_price: float, funding_rate: float = 0.0): + """Mark-to-market and funding update (call periodically).""" + if self._position != 0 and self._entry_price != 0: + self._inv_pnl = self._position * (mid_price - self._entry_price) + + if self._position != 0 and abs(funding_rate) > 1e-10: + funding = self._position * mid_price * funding_rate + self._funding_pnl += funding + self._equity += funding + + self._entry_price = mid_price + self._update_drawdown() + self._equity_curve.append({"t": sim_time, "v": self._equity}) + self._sim_duration = sim_time + + def record_spread(self, spread_bps: float): + self._spreads.append(spread_bps) + + def record_downtime(self, duration: float): + self._downtime_total += duration + + def record_latency(self, latency_ms: float): + self._latencies.append(latency_ms) + + def _update_drawdown(self): + if self._equity > self._peak_equity: + self._peak_equity = self._equity + dd = (self._peak_equity - self._equity) / self._peak_equity if self._peak_equity > 0 else 0 + self._max_dd = max(self._max_dd, dd) + + def net_pnl(self) -> float: + return self._equity - self._initial_equity + + def breakdown(self) -> PnLBreakdown: + return PnLBreakdown( + spread_capture=round(self._spread_capture, 4), + inventory_pnl=round(self._inv_pnl, 4), + maker_fees=round(self._maker_fees, 4), + taker_fees=round(self._taker_fees, 4), + funding_pnl=round(self._funding_pnl, 4), + adverse_selection_cost=round(self._adverse_cost, 4), + gross_pnl=round(self._gross_pnl, 4), + net_pnl=round(self.net_pnl(), 4), + ) + + def stats(self) -> SimulationStats: + import math + + eq_vals = [p["v"] for p in self._equity_curve] + returns = [] + for i in range(1, len(eq_vals)): + if eq_vals[i - 1] > 0: + returns.append(math.log(eq_vals[i] / eq_vals[i - 1])) + + sharpe = 0.0 + sortino = 0.0 + if returns and self._sim_duration > 0: + mean_r = sum(returns) / len(returns) + std_r = (sum((r - mean_r) ** 2 for r in returns) / max(len(returns) - 1, 1)) ** 0.5 + if std_r > 0: + sharpe = mean_r / std_r * math.sqrt(365 * 24 * 3600 / max(self._sim_duration, 1)) + down = [r for r in returns if r < 0] + down_std = (sum((r - mean_r) ** 2 for r in down) / max(len(down) - 1, 1)) ** 0.5 if down else 0 + if down_std > 0: + sortino = mean_r / down_std * math.sqrt(365 * 24 * 3600 / max(self._sim_duration, 1)) + + uptime = 100 - (self._downtime_total / max(self._sim_duration, 1) * 100) + + return SimulationStats( + total_trades=self._total_trades, + bid_fills=self._bid_fills, + ask_fills=self._ask_fills, + cancels=self._cancels, + toxic_fills=self._toxic_fills, + adverse_rate=round(self._toxic_fills / max(self._total_trades, 1), 4), + avg_fill_size=round((abs(self._position) / max(self._total_trades, 1)), 6) if self._total_trades > 0 else 0, + avg_spread_bps=round(sum(self._spreads) / max(len(self._spreads), 1), 2), + max_inventory=round(self._max_inventory, 6), + max_drawdown=round(self._max_dd * 100, 2), + sharpe=round(sharpe, 3), + sortino=round(sortino, 3), + uptime_pct=round(uptime, 1), + avg_latency_ms=round(sum(self._latencies) / max(len(self._latencies), 1), 2), + pnl=self.breakdown(), + ) + + @property + def equity_curve(self) -> list[dict]: + return self._equity_curve + + @property + def position(self) -> float: + return self._position diff --git a/sim/scenario.py b/sim/scenario.py new file mode 100644 index 0000000..f24229b --- /dev/null +++ b/sim/scenario.py @@ -0,0 +1,172 @@ +""" +Scenario simulation: exchange downtime, volatility bursts, regime switches. + +Models realistic exchange behaviors that affect market-making performance. +""" + +from __future__ import annotations + +import random +from dataclasses import dataclass, field +from typing import Optional + + +@dataclass +class ExchangeDowntime: + """Scheduled or unscheduled exchange outage.""" + + start_time: float + end_time: float + reason: str = "scheduled_maintenance" + + def is_active(self, sim_time: float) -> bool: + return self.start_time <= sim_time < self.end_time + + @property + def duration_seconds(self) -> float: + return self.end_time - self.start_time + + +@dataclass +class LatencySpike: + """Temporary latency increase.""" + + start_time: float + end_time: float + multiplier: float = 5.0 # 5x normal latency + + def is_active(self, sim_time: float) -> bool: + return self.start_time <= sim_time < self.end_time + + +@dataclass +class VolatilityBurst: + """Sudden increase in volatility.""" + + start_time: float + end_time: float + vol_multiplier: float = 3.0 + + def is_active(self, sim_time: float) -> bool: + return self.start_time <= sim_time < self.end_time + + +@dataclass +class ScenarioConfig: + """Configuration for simulation scenarios.""" + + duration_seconds: float = 3600.0 # 1 hour default + seed: int | None = None + + # Exchange behavior + downtime_prob: float = 0.0 # probability of a downtime event + downtime_duration_ms: int = 30000 # 30s typical + latency_spike_prob: float = 0.05 + latency_spike_ms: int = 5000 + latency_multiplier: float = 5.0 + + # Market behavior + volatility_burst_prob: float = 0.02 + volatility_burst_ms: int = 60000 + vol_multiplier: float = 3.0 + + # Trade intensity + base_trade_rate: float = 1.0 # trades per second + burst_trade_rate: float = 5.0 # trades per second during bursts + + # Spread behavior + base_spread_bps: float = 1.5 + wide_spread_bps: float = 15.0 + + +class ScenarioEngine: + """Generates and manages simulation scenarios.""" + + def __init__(self, config: ScenarioConfig | None = None, seed: int | None = None): + self._cfg = config or ScenarioConfig() + self._rng = random.Random(seed or self._cfg.seed) + self._downtimes: list[ExchangeDowntime] = [] + self._latency_spikes: list[LatencySpike] = [] + self._volatility_bursts: list[VolatilityBurst] = [] + + def generate(self, duration: float | None = None): + """Pre-generate random scenarios for the simulation duration.""" + d = duration or self._cfg.duration_seconds + self._downtimes.clear() + self._latency_spikes.clear() + self._volatility_bursts.clear() + + t = 0.0 + while t < d: + t += self._rng.expovariate(1.0 / (d / 100)) + + if self._rng.random() < self._cfg.downtime_prob: + dur = self._rng.uniform(self._cfg.downtime_duration_ms / 1000 * 0.5, + self._cfg.downtime_duration_ms / 1000 * 2) + self._downtimes.append(ExchangeDowntime(t, t + dur)) + + if self._rng.random() < self._cfg.latency_spike_prob: + dur = self._rng.uniform(self._cfg.latency_spike_ms / 1000 * 0.5, + self._cfg.latency_spike_ms / 1000 * 2) + self._latency_spikes.append( + LatencySpike(t, t + dur, self._cfg.latency_multiplier) + ) + + if self._rng.random() < self._cfg.volatility_burst_prob: + dur = self._rng.uniform(self._cfg.volatility_burst_ms / 1000 * 0.5, + self._cfg.volatility_burst_ms / 1000 * 2) + self._volatility_bursts.append( + VolatilityBurst(t, t + dur, self._cfg.vol_multiplier) + ) + + def is_exchange_up(self, sim_time: float) -> bool: + return not any(d.is_active(sim_time) for d in self._downtimes) + + def current_latency_multiplier(self, sim_time: float) -> float: + for spike in self._latency_spikes: + if spike.is_active(sim_time): + return spike.multiplier + return 1.0 + + def current_vol_multiplier(self, sim_time: float) -> float: + for burst in self._volatility_bursts: + if burst.is_active(sim_time): + return burst.vol_multiplier + return 1.0 + + def current_trade_rate(self, sim_time: float) -> float: + if self.current_vol_multiplier(sim_time) > 2.0: + return self._cfg.burst_trade_rate + return self._cfg.base_trade_rate + + def current_spread_bps(self, sim_time: float) -> float: + if self.current_vol_multiplier(sim_time) > 2.0: + return self._cfg.wide_spread_bps + return self._cfg.base_spread_bps + + def state(self, sim_time: float) -> dict: + return { + "exchange_up": self.is_exchange_up(sim_time), + "latency_multiplier": self.current_latency_multiplier(sim_time), + "vol_multiplier": self.current_vol_multiplier(sim_time), + "trade_rate": self.current_trade_rate(sim_time), + "spread_bps": self.current_spread_bps(sim_time), + } + + def active_downtime(self, sim_time: float) -> Optional[ExchangeDowntime]: + for d in self._downtimes: + if d.is_active(sim_time): + return d + return None + + @property + def downtimes(self) -> list[ExchangeDowntime]: + return self._downtimes + + @property + def latency_spikes(self) -> list[LatencySpike]: + return self._latency_spikes + + @property + def volatility_bursts(self) -> list[VolatilityBurst]: + return self._volatility_bursts diff --git a/tests/test_sim_constraints.py b/tests/test_sim_constraints.py new file mode 100644 index 0000000..2042e1c --- /dev/null +++ b/tests/test_sim_constraints.py @@ -0,0 +1,96 @@ +""" +Tests for sim/constraints.py — inventory, funding, fees, liquidation, circuit breakers. +""" +from sim.constraints import ( + InventoryConstraint, + FundingConstraint, + FeeSchedule, + LiquidationRisk, + CircuitBreaker, + ConstraintManager, +) + + +class TestInventoryConstraint: + def test_within_limits(self): + ic = InventoryConstraint(max_long=0.01, max_short=0.01) + result = ic.check(0.005, 0.003) + assert result["long_ok"] + assert result["short_ok"] + assert result["net_ok"] + + def test_long_exceeded(self): + ic = InventoryConstraint(max_long=0.005) + result = ic.check(0.006, 0.001) + assert not result["long_ok"] + + +class TestFundingConstraint: + def test_cost_positive_funding(self): + fc = FundingConstraint(funding_rate_hourly=0.0001) + cost = fc.cost_per_hour(position=1.0, mark_price=100000.0) + assert cost == 10.0 # 1 * 100000 * 0.0001 = 10 + + def test_annualized(self): + fc = FundingConstraint(funding_rate_hourly=0.0001) + ann = fc.annualized_cost_pct(0.0001) + assert ann == 0.0001 * 365 * 24 * 100 + + +class TestFeeSchedule: + def test_maker_fee(self): + fs = FeeSchedule(maker_fee_pct=0.0002) + assert fs.maker_fee(100000.0) == 20.0 + + def test_taker_fee(self): + fs = FeeSchedule(taker_fee_pct=0.0005) + assert fs.taker_fee(100000.0) == 50.0 + + +class TestLiquidationRisk: + def test_liquidation_price_long(self): + lr = LiquidationRisk(maintenance_margin_pct=0.03, initial_margin_pct=0.05) + liq = lr.liquidation_price(entry_price=100000.0, size=1.0, position_side="long", wallet_balance=10000.0) + assert 0 < liq < 100000.0 # below entry + + def test_liquidation_price_short(self): + lr = LiquidationRisk(maintenance_margin_pct=0.03, initial_margin_pct=0.05) + liq = lr.liquidation_price(entry_price=100000.0, size=1.0, position_side="short", wallet_balance=10000.0) + assert liq > 100000.0 # above entry + + def test_safe_distance(self): + lr = LiquidationRisk() + assert lr.is_safe(5.0, threshold_pct=2.0) + assert not lr.is_safe(1.0, threshold_pct=2.0) + + +class TestCircuitBreaker: + def test_no_trip(self): + cb = CircuitBreaker() + result = cb.evaluate({"pnl_pct": -1.0, "daily_trades": 10, "toxic_rate": 0.1, "api_errors": 0}) + assert not result["tripped"] + + def test_drawdown_trip(self): + cb = CircuitBreaker(max_drawdown_pct=-5.0) + result = cb.evaluate({"pnl_pct": -6.0, "daily_trades": 10, "toxic_rate": 0.1, "api_errors": 0}) + assert result["tripped"] + + def test_trade_count_trip(self): + cb = CircuitBreaker(max_daily_trades=100) + result = cb.evaluate({"pnl_pct": -1.0, "daily_trades": 150, "toxic_rate": 0.1, "api_errors": 0}) + assert result["tripped"] + + +class TestConstraintManager: + def test_can_place_bid(self): + cm = ConstraintManager() + result = cm.can_place(side="bid", size=0.001, current_position=0.0, mark_price=100000.0) + assert result["allowed"] + assert round(result["fee_estimate"], 4) == 0.02 # 0.001 * 100000 * 0.0002 = 0.02 + + def test_cannot_exceed_inventory(self): + cm = ConstraintManager( + inventory=InventoryConstraint(max_long=0.002, max_short=0.002) + ) + result = cm.can_place(side="bid", size=0.001, current_position=0.0015, mark_price=100000.0) + assert not result["allowed"] diff --git a/tests/test_sim_engine.py b/tests/test_sim_engine.py new file mode 100644 index 0000000..1b4b2bf --- /dev/null +++ b/tests/test_sim_engine.py @@ -0,0 +1,81 @@ +""" +Tests for sim/engine.py — full simulation engine integration. +""" +from sim.engine import SimulationEngine, SimConfig +from sim.maker import MakerConfig +from sim.fills import FillModelConfig +from sim.scenario import ScenarioConfig + + +class TestSimulationEngine: + def test_empty_events(self): + engine = SimulationEngine() + engine.run([]) + assert engine.reporter.net_pnl() == 0.0 + + def test_no_fills_with_no_trades(self): + engine = SimulationEngine() + events = [ + {"type": "l2", "data": {"bids": {50000.0: 1.0}, "asks": {50002.0: 1.0}}, "time": 0.0, "coin": "BTC"}, + {"type": "l2", "data": {"bids": {50001.0: 1.0}, "asks": {50003.0: 1.0}}, "time": 1.0, "coin": "BTC"}, + ] + engine.run(events) + assert engine.stats().total_trades == 0 + + def test_trade_fills_ask(self): + engine = SimulationEngine( + config=SimConfig( + maker=MakerConfig(base_size=0.001), + fills=FillModelConfig(partial_fill_prob=0.0, adverse_selection_prob=0.0), + cancel_after_ms=999999, # don't cancel during test + seed=42, + ) + ) + events = [ + {"type": "l2", "data": {"bids": {50000.0: 1.0}, "asks": {50002.0: 1.0}}, "time": 0.0, "coin": "BTC"}, + {"type": "l2", "data": {"bids": {50000.0: 1.0}, "asks": {50002.0: 1.0}}, "time": 3.0, "coin": "BTC"}, # trigger quote + {"type": "trade", "data": {"px": 50002.0, "sz": 0.001, "side": "B"}, "time": 4.0, "coin": "BTC"}, + ] + engine.run(events) + assert engine.stats().total_trades >= 0 # may or may not fill depending on queue + + def test_scenario_generation(self): + engine = SimulationEngine( + config=SimConfig( + scenario=ScenarioConfig(downtime_prob=0.0, latency_spike_prob=0.0, volatility_burst_prob=0.0, seed=42), + seed=42, + ) + ) + events = [ + {"type": "l2", "data": {"bids": {50000.0: 1.0}, "asks": {50002.0: 1.0}}, "time": 0.0, "coin": "BTC"}, + {"type": "l2", "data": {"bids": {}, "asks": {}}, "time": 10.0, "coin": "BTC"}, + ] + engine.run(events) + stats = engine.stats() + assert stats.uptime_pct >= 0 + + def test_circuit_breaker_not_tripped(self): + engine = SimulationEngine( + config=SimConfig( + maker=MakerConfig(base_size=0.001), + fills=FillModelConfig(partial_fill_prob=0.0, adverse_selection_prob=0.0), + cancel_after_ms=999999, + seed=42, + ) + ) + events = [ + {"type": "l2", "data": {"bids": {50000.0: 1.0}, "asks": {50002.0: 1.0}}, "time": 0.0, "coin": "BTC"}, + {"type": "l2", "data": {"bids": {50000.0: 1.0}, "asks": {50002.0: 1.0}}, "time": 3.0, "coin": "BTC"}, + ] + engine.run(events) + # Should not be halted (no trades, PnL = 0) + assert engine.stats().pnl.net_pnl == 0.0 + + def test_maker_prices_never_cross(self): + """Quotes should have bid < mid if no inventory, ask > mid.""" + maker_conf = MakerConfig(base_size=0.001, gamma=0.1, min_spread_bps=1.0) + from sim.maker import AvellanedaStoikovMaker + maker = AvellanedaStoikovMaker(maker_conf) + maker.observe(100000.0) + q0 = maker.quote(100000.0, 0.0, 0.0) + assert q0.bid < 100000.0 < q0.ask diff --git a/tests/test_sim_maker.py b/tests/test_sim_maker.py new file mode 100644 index 0000000..87b6900 --- /dev/null +++ b/tests/test_sim_maker.py @@ -0,0 +1,69 @@ +""" +Tests for sim/maker.py — market-making quoting logic. +""" +from sim.maker import AvellanedaStoikovMaker, GridMaker, MakerConfig, Quote + + +class TestAvellanedaStoikovMaker: + def test_initial_sigma(self): + maker = AvellanedaStoikovMaker() + assert maker.sigma == 0.02 # default + + def test_observe_updates_volatility(self): + maker = AvellanedaStoikovMaker() + for i in range(20): + maker.observe(100000.0 + i * 100) + assert maker.sigma > 0 + + def test_quote_no_inventory(self): + maker = AvellanedaStoikovMaker() + maker.observe(100000.0) + q = maker.quote(mid_price=100000.0, inventory=0.0, elapsed_hours=0.0) + assert q.bid > 0 + assert q.ask > q.bid + assert q.spread_bps >= 0 + + def test_quote_short_inventory_skew(self): + maker = AvellanedaStoikovMaker(MakerConfig(gamma=0.5, k=1.0)) + for i in range(50): + maker.observe(100000.0 + i * 50 * (1 if i % 2 == 0 else -1)) + q_short = maker.quote(mid_price=100000.0, inventory=-0.003, elapsed_hours=0.0) + q_long = maker.quote(mid_price=100000.0, inventory=0.003, elapsed_hours=0.0) + assert q_short.reservation > q_long.reservation + + def test_quote_long_inventory_skew(self): + maker = AvellanedaStoikovMaker() + maker.observe(100000.0) + q = maker.quote(mid_price=100000.0, inventory=0.004, elapsed_hours=0.1) + assert q.reservation < q.ask + + def test_quote_with_skew(self): + maker = AvellanedaStoikovMaker() + maker.observe(100000.0) + q = maker.quote_with_skew(mid_price=100000.0, inventory=0.003, elapsed_hours=0.0, target_inventory=0.0) + assert q.ask_size > q.bid_size # long → bigger asks to reduce position + + def test_elapsed_reduces_spread(self): + maker = AvellanedaStoikovMaker() + maker.observe(100000.0) + q_early = maker.quote(mid_price=100000.0, inventory=0.0, elapsed_hours=0.0) + q_late = maker.quote(mid_price=100000.0, inventory=0.0, elapsed_hours=0.9) + # Near session end → narrower spread (less time to recover) + assert q_late.spread_bps <= q_early.spread_bps + 1 + + +class TestGridMaker: + def test_grid_levels(self): + gm = GridMaker(grid_levels=3, spacing_bps=10.0, size_per_level=0.001) + quotes = gm.quotes(100000.0) + assert len(quotes) == 6 # 3 bid + 3 ask levels + + def test_symmetric(self): + gm = GridMaker(grid_levels=2, spacing_bps=50.0, size_per_level=0.001) + quotes = gm.quotes(100000.0) + bids = [q for q in quotes if q["side"] == "bid"] + asks = [q for q in quotes if q["side"] == "ask"] + assert len(bids) == 2 + assert len(asks) == 2 + for b, a in zip(bids[::-1], asks): + assert a["price"] > b["price"] diff --git a/tests/test_sim_queue.py b/tests/test_sim_queue.py new file mode 100644 index 0000000..48d8136 --- /dev/null +++ b/tests/test_sim_queue.py @@ -0,0 +1,95 @@ +""" +Tests for sim/queue.py — queue position model. +""" +from sim.queue import QueueModel, QueuePosition, fill_probability + + +class TestQueueModel: + def test_place_bid(self): + qm = QueueModel() + oid = qm.place_order("bid", 50000.0, 0.01, sim_time=100.0) + tob = qm.top_of_book() + assert tob["best_bid"] == 50000.0 + assert tob["best_ask"] == 0 # no asks placed + + def test_queue_position_is_front(self): + qm = QueueModel() + oid = qm.place_order("ask", 50002.0, 0.01, sim_time=100.0) + qp = qm.queue_position("ask", 50002.0, oid) + assert qp is not None + assert qp.position == 0 + assert qp.is_front + + def test_queue_position_behind_others(self): + qm = QueueModel() + qm.place_order("bid", 50000.0, 0.01, sim_time=99.0) + oid2 = qm.place_order("bid", 50000.0, 0.01, sim_time=100.0) + qp = qm.queue_position("bid", 50000.0, oid2) + assert qp is not None + assert qp.position == 1 # behind first order + + def test_cancel_order(self): + qm = QueueModel() + oid = qm.place_order("bid", 50000.0, 0.01, sim_time=100.0) + filled = qm.cancel_order(oid, sim_time=105.0) + assert filled == 0.0 + assert qm.order_status(oid)["status"] == "cancelled" + assert len(qm.active_orders()) == 0 + + def test_trade_eats_ask(self): + qm = QueueModel() + oid = qm.place_order("ask", 50002.0, 0.01, sim_time=100.0) + fills = qm.process_trade("buy", 50002.0, 0.01, sim_time=101.0) + assert len(fills) == 1 + assert fills[0]["size"] == 0.01 + + def test_trade_eats_bid(self): + qm = QueueModel() + oid = qm.place_order("bid", 50000.0, 0.02, sim_time=100.0) + fills = qm.process_trade("sell", 50000.0, 0.01, sim_time=101.0) + assert fills[0]["size"] == 0.01 + assert qm.order_status(oid)["filled"] == 0.01 + + def test_partial_fill_remaining(self): + qm = QueueModel() + oid = qm.place_order("bid", 50000.0, 0.03, sim_time=100.0) + qm.process_trade("sell", 50000.0, 0.01, sim_time=101.0) + status = qm.order_status(oid) + assert status["filled"] == 0.01 + assert status["status"] == "active" + + def test_fully_filled_status(self): + qm = QueueModel() + oid = qm.place_order("bid", 50000.0, 0.01, sim_time=100.0) + qm.process_trade("sell", 50000.0, 0.01, sim_time=101.0) + assert qm.order_status(oid)["status"] == "filled" + + def test_trade_crosses_spread(self): + qm = QueueModel() + qm.place_order("ask", 50002.0, 0.01, sim_time=100.0) + qm.place_order("ask", 50003.0, 0.01, sim_time=100.0) + fills = qm.process_trade("buy", 50003.0, 0.02, sim_time=101.0) + assert len(fills) == 2 + + def test_top_of_book(self): + qm = QueueModel() + qm.place_order("bid", 50000.0, 0.01, sim_time=100.0) + qm.place_order("ask", 50002.0, 0.02, sim_time=100.0) + tob = qm.top_of_book() + assert tob["best_bid"] == 50000.0 + assert tob["best_ask"] == 50002.0 + assert tob["spread"] == 2.0 + + +class TestFillProbability: + def test_front_of_queue(self): + result = fill_probability(queue_pos=0, total_queue_depth=1.0, order_size=0.01, arrival_rate=1.0, time_horizon=1.0) + assert result["fill_probability"] > 0.5 + + def test_deep_in_queue(self): + result = fill_probability(queue_pos=5, total_queue_depth=10.0, order_size=0.01, arrival_rate=1.0, time_horizon=1.0) + assert result["fill_probability"] < 0.5 + + def test_no_arrivals_zero_prob(self): + result = fill_probability(queue_pos=0, total_queue_depth=1.0, order_size=0.01, arrival_rate=0.0, time_horizon=1.0) + assert result["fill_probability"] == 0.0 diff --git a/tests/test_sim_reporter.py b/tests/test_sim_reporter.py new file mode 100644 index 0000000..ba6b5e4 --- /dev/null +++ b/tests/test_sim_reporter.py @@ -0,0 +1,88 @@ +""" +Tests for sim/reporter.py — PnL breakdown and stats. +""" +from sim.reporter import PnLReporter, PnLBreakdown, SimulationStats + + +class TestPnlReporter: + def test_initial_equity(self): + r = PnLReporter(initial_equity=10000.0) + assert r.net_pnl() == 0.0 + + def test_maker_fill_bid(self): + r = PnLReporter(initial_equity=10000.0) + size = 0.001 + fee = size * 50000.0 * 0.0002 # 0.01 + r.record_maker_fill(side="bid", price=50000.0, size=size, mid_price=50001.0, fee=fee) + bd = r.breakdown() + assert bd.spread_capture > 0 # bought below mid + assert bd.maker_fees < 0 + + def test_maker_fill_ask(self): + r = PnLReporter(initial_equity=10000.0) + size = 0.001 + fee = size * 50001.0 * 0.0002 + r.record_maker_fill(side="ask", price=50001.0, size=size, mid_price=50000.0, fee=fee) + bd = r.breakdown() + assert bd.spread_capture > 0 # sold above mid + + def test_toxic_fill(self): + r = PnLReporter(initial_equity=10000.0) + size = 0.001 + fee = size * 50000.0 * 0.0002 + r.record_maker_fill(side="bid", price=50000.0, size=size, mid_price=50000.5, fee=fee, is_toxic=True) + bd = r.breakdown() + assert bd.adverse_selection_cost > 0 + + def test_inventory_tracking(self): + r = PnLReporter(initial_equity=10000.0) + r.record_maker_fill(side="bid", price=50000.0, size=0.002, mid_price=50001.0, fee=1.0) + assert r.position == 0.002 + r.record_maker_fill(side="ask", price=50001.0, size=0.002, mid_price=50000.0, fee=1.0) + assert abs(r.position) < 1e-10 + + def test_equity_curve(self): + r = PnLReporter(initial_equity=10000.0) + r.record_equity_update(sim_time=1.0, mid_price=100000.0) + curve = r.equity_curve + assert len(curve) >= 2 + assert curve[0]["v"] == 10000.0 + + def test_max_drawdown(self): + r = PnLReporter(initial_equity=10000.0) + r.record_maker_fill(side="bid", price=100000.0, size=0.001, mid_price=99900.0, fee=1.0) # loss + assert r.stats().max_drawdown > 0 + + def test_stats_includes_all_components(self): + r = PnLReporter(initial_equity=10000.0) + r.record_maker_fill(side="bid", price=50000.0, size=0.001, mid_price=50001.0, fee=10.0) + r.record_maker_fill(side="ask", price=50001.0, size=0.001, mid_price=50000.0, fee=10.0) + r.record_spread(1.5) + r.record_latency(25.0) + stats = r.stats() + assert stats.total_trades == 2 + assert stats.pnl.spread_capture > 0 + assert stats.pnl.net_pnl != 0 + assert stats.avg_spread_bps > 0 + + def test_breakdown_all_fields(self): + r = PnLReporter(initial_equity=10000.0) + bd = r.breakdown() + assert bd.spread_capture == 0 + assert bd.inventory_pnl == 0 + assert bd.maker_fees == 0 + assert bd.taker_fees == 0 + assert bd.net_pnl == 0 + + def test_cancel_counting(self): + r = PnLReporter(initial_equity=10000.0) + r.record_cancel() + r.record_cancel() + assert r.stats().cancels == 2 + + def test_downtime(self): + r = PnLReporter(initial_equity=10000.0) + r.record_downtime(60.0) + r.record_equity_update(sim_time=100.0, mid_price=100000.0) + stats = r.stats() + assert stats.uptime_pct < 100.0