Files
ramseshk 639dd4fb6d 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.
2026-08-07 14:39:59 +08:00

235 lines
7.8 KiB
Python

"""
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