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:
ramseshk
2026-08-07 14:39:59 +08:00
parent fcfc136384
commit 639dd4fb6d
13 changed files with 1989 additions and 0 deletions
+55
View File
@@ -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",
]
+183
View File
@@ -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,
}
+291
View File
@@ -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()
+182
View File
@@ -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),
}
+181
View File
@@ -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
+262
View File
@@ -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,
}
+234
View File
@@ -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
+172
View File
@@ -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
+96
View File
@@ -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"]
+81
View File
@@ -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
+69
View File
@@ -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"]
+95
View File
@@ -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
+88
View File
@@ -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