639dd4fb6d
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.
235 lines
7.8 KiB
Python
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
|