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:
+234
@@ -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
|
||||
Reference in New Issue
Block a user