Files
ramseshk 50d63e1ecc feat: HFT infrastructure — tick backtest runner, VPIN-gated A-S maker, WQI predictor, queue-aware fills
- backtests/tick_runner.py: TickBacktestRunner replays stored Parquet L2/trade events
  through sim/engine.py with queue position modeling, producing PnL breakdowns,
  equity curves, VPIN curves, and QuantVerdict significance reports
- VPINGatedASMaker: VPIN-toxicity-gated A-S market maker with inventory skew
  and dynamic spread widening; blocks quoting when VPIN >= alarm threshold
- sim/engine.py: Added SimConfig.from_fee_tier() factory — constructs sim
  config from Hyperliquid fee tier (VIP + staking)
- sim/fills.py: Added QueueAwareFillModel — realistic queue-priority fill
  simulation replacing random fills in paper trading
- strategies/wqi_predictor.py: WQI z-score directional strategy with
  adverse selection gating, timeout exit, stop-loss, and take-profit
- cli.py: Added 'tick', 'markout' analysis, and 'discover' signal-discovery
  commands for end-to-end tick-level HFT research pipeline

301 tests passing (23 new).
2026-08-11 10:43:51 +08:00

313 lines
10 KiB
Python

"""
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
quote_refresh_ms: float = 2000.0
seed: int | None = None
@classmethod
def from_fee_tier(
cls,
vip_tier: int = 0,
staking_tier: str = "none",
maker_rebate_tier: int = 0,
**kwargs,
) -> "SimConfig":
from config.fee_tiers import get_perp_fees, PERPS_TIERS, STAKING_TIERS
maker_fee = get_perp_fees(vip_tier, staking_tier, "maker", maker_rebate_tier)
taker_fee = get_perp_fees(vip_tier, staking_tier, "taker", maker_rebate_tier)
tier_name = PERPS_TIERS[vip_tier]["name"]
staking_name = STAKING_TIERS.get(staking_tier, STAKING_TIERS["none"])["name"]
return cls(
maker_fee_pct=maker_fee,
taker_fee_pct=taker_fee,
**kwargs,
)
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()