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.
184 lines
6.4 KiB
Python
184 lines
6.4 KiB
Python
"""
|
|
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,
|
|
}
|