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