Files
ftdt-quant-lab/sim/constraints.py
T
ramseshk 639dd4fb6d 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.
2026-08-07 14:39:59 +08:00

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,
}