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).
This commit is contained in:
@@ -0,0 +1,228 @@
|
||||
"""
|
||||
Tests for tick-level backtest runner and queue-aware fill model.
|
||||
"""
|
||||
from sim.fills import QueueAwareFillModel, FillModelConfig, FillSimulator
|
||||
from sim.maker import MakerConfig
|
||||
from sim.engine import SimConfig
|
||||
|
||||
|
||||
class TestQueueAwareFillModel:
|
||||
def test_price_not_crossed(self):
|
||||
qm = QueueAwareFillModel()
|
||||
result = qm.check_fill(
|
||||
aggressor_side="buy",
|
||||
agg_size=0.01,
|
||||
agg_price=49900.0,
|
||||
our_price=50000.0,
|
||||
our_size=0.001,
|
||||
depth_ahead=0.0,
|
||||
)
|
||||
assert not result["filled"]
|
||||
assert result["reason"] == "price_not_crossed"
|
||||
|
||||
def test_fills_when_price_crossed_and_no_queue_ahead(self):
|
||||
qm = QueueAwareFillModel()
|
||||
result = qm.check_fill(
|
||||
aggressor_side="buy",
|
||||
agg_size=0.01,
|
||||
agg_price=50005.0,
|
||||
our_price=50000.0,
|
||||
our_size=0.001,
|
||||
depth_ahead=0.0,
|
||||
)
|
||||
assert result["filled"]
|
||||
assert result["fill_size"] == 0.001
|
||||
|
||||
def test_does_not_fill_when_queue_not_reached(self):
|
||||
qm = QueueAwareFillModel()
|
||||
result = qm.check_fill(
|
||||
aggressor_side="buy",
|
||||
agg_size=0.001,
|
||||
agg_price=50005.0,
|
||||
our_price=50000.0,
|
||||
our_size=0.001,
|
||||
depth_ahead=0.005,
|
||||
)
|
||||
assert not result["filled"]
|
||||
assert result["reason"] == "queue_not_reached"
|
||||
|
||||
def test_partial_fill(self):
|
||||
qm = QueueAwareFillModel()
|
||||
result = qm.check_fill(
|
||||
aggressor_side="sell",
|
||||
agg_size=0.005,
|
||||
agg_price=49990.0,
|
||||
our_price=50000.0,
|
||||
our_size=0.003,
|
||||
depth_ahead=0.002,
|
||||
)
|
||||
assert result["filled"]
|
||||
assert result["fill_size"] == 0.003
|
||||
|
||||
def test_sell_fill_price_match(self):
|
||||
qm = QueueAwareFillModel()
|
||||
result = qm.check_fill(
|
||||
aggressor_side="sell",
|
||||
agg_size=0.01,
|
||||
agg_price=49990.0,
|
||||
our_price=50000.0,
|
||||
our_size=0.001,
|
||||
depth_ahead=0.0,
|
||||
)
|
||||
assert result["filled"]
|
||||
|
||||
def test_estimate_depth_ahead_at_best(self):
|
||||
qm = QueueAwareFillModel()
|
||||
depth = qm.estimate_depth_ahead(
|
||||
our_price=50000.0,
|
||||
our_side="bid",
|
||||
best_bid=50000.0,
|
||||
best_ask=50002.0,
|
||||
bid_depth=2.0,
|
||||
ask_depth=1.0,
|
||||
)
|
||||
assert depth == 1.0
|
||||
|
||||
def test_estimate_depth_ahead_not_at_best(self):
|
||||
qm = QueueAwareFillModel()
|
||||
depth = qm.estimate_depth_ahead(
|
||||
our_price=49999.0,
|
||||
our_side="bid",
|
||||
best_bid=50000.0,
|
||||
best_ask=50002.0,
|
||||
bid_depth=2.0,
|
||||
ask_depth=1.0,
|
||||
)
|
||||
assert depth == float("inf")
|
||||
|
||||
def test_fill_rate_tracking(self):
|
||||
qm = QueueAwareFillModel()
|
||||
qm.check_fill("buy", 0.01, 50005.0, 50000.0, 0.001, 0.0)
|
||||
qm.check_fill("buy", 0.001, 50005.0, 50000.0, 0.001, 0.005)
|
||||
qm.check_fill("buy", 0.01, 50005.0, 50000.0, 0.001, 0.0)
|
||||
assert qm.fill_count == 2
|
||||
assert qm.skip_count == 1
|
||||
assert qm.fill_rate() == 2 / 3
|
||||
|
||||
|
||||
class TestSimConfigFeeTier:
|
||||
def test_from_fee_tier_default(self):
|
||||
cfg = SimConfig.from_fee_tier(vip_tier=0)
|
||||
assert cfg.maker_fee_pct == 0.00015
|
||||
assert cfg.taker_fee_pct == 0.00045
|
||||
|
||||
def test_from_fee_tier_vip2(self):
|
||||
cfg = SimConfig.from_fee_tier(vip_tier=2)
|
||||
assert cfg.maker_fee_pct == 0.00008
|
||||
assert cfg.taker_fee_pct == 0.00035
|
||||
|
||||
def test_from_fee_tier_with_staking(self):
|
||||
cfg = SimConfig.from_fee_tier(vip_tier=0, staking_tier="gold")
|
||||
assert cfg.maker_fee_pct < 0.00015
|
||||
assert cfg.taker_fee_pct < 0.00045
|
||||
|
||||
def test_from_fee_tier_custom_params(self):
|
||||
cfg = SimConfig.from_fee_tier(
|
||||
vip_tier=0,
|
||||
max_inventory=0.01,
|
||||
initial_equity=50000.0,
|
||||
)
|
||||
assert cfg.max_inventory == 0.01
|
||||
assert cfg.initial_equity == 50000.0
|
||||
|
||||
|
||||
class TestVPINGatedASMaker:
|
||||
def test_default_allows_quoting(self):
|
||||
from backtests.tick_runner import VPINGatedASMaker
|
||||
maker = VPINGatedASMaker(MakerConfig())
|
||||
assert maker.allowed_to_quote() == (True, 1.0)
|
||||
|
||||
def test_alarm_blocks_quoting(self):
|
||||
from backtests.tick_runner import VPINGatedASMaker
|
||||
maker = VPINGatedASMaker(MakerConfig(), vpin_threshold=0.3, vpin_alarm=0.5)
|
||||
maker._current_vpin = 0.55
|
||||
assert maker.allowed_to_quote() == (False, 0.0)
|
||||
|
||||
def test_threshold_reduces_size(self):
|
||||
from backtests.tick_runner import VPINGatedASMaker
|
||||
maker = VPINGatedASMaker(MakerConfig(), vpin_threshold=0.3, vpin_alarm=0.5)
|
||||
maker._current_vpin = 0.35
|
||||
allowed, size_mult = maker.allowed_to_quote()
|
||||
assert allowed
|
||||
assert 0 < size_mult < 1.0
|
||||
|
||||
def test_returns_none_when_not_allowed(self):
|
||||
from backtests.tick_runner import VPINGatedASMaker
|
||||
maker = VPINGatedASMaker(MakerConfig(), vpin_threshold=0.3, vpin_alarm=0.5)
|
||||
maker._current_vpin = 0.55
|
||||
maker.observe(100000.0)
|
||||
q = maker.quote(100000.0, 0.0, 0.0)
|
||||
assert q is None
|
||||
|
||||
def test_returns_quote_when_allowed(self):
|
||||
from backtests.tick_runner import VPINGatedASMaker
|
||||
maker = VPINGatedASMaker(MakerConfig())
|
||||
maker.observe(100000.0)
|
||||
maker.observe(100100.0)
|
||||
maker.observe(100050.0)
|
||||
q = maker.quote(100000.0, 0.0, 0.0)
|
||||
assert q is not None
|
||||
assert q.bid < q.ask
|
||||
assert q.bid_size > 0
|
||||
|
||||
|
||||
class TestWQIPredictor:
|
||||
def test_initial_state(self):
|
||||
from strategies.wqi_predictor import WQIPredictor
|
||||
wqi = WQIPredictor()
|
||||
assert wqi.position == 0
|
||||
assert len(wqi.trades) == 0
|
||||
|
||||
def test_no_signal_with_balanced_book(self):
|
||||
from strategies.wqi_predictor import WQIPredictor
|
||||
wqi = WQIPredictor()
|
||||
bids = [(100.0, 1.0), (99.0, 1.0)]
|
||||
asks = [(102.0, 1.0), (103.0, 1.0)]
|
||||
signal = wqi.feed_signal(bids, asks, 101.0)
|
||||
assert signal["action"] == "HOLD"
|
||||
|
||||
def test_buy_signal_with_bid_heavy_book(self):
|
||||
from strategies.wqi_predictor import WQIPredictor
|
||||
wqi = WQIPredictor(z_entry=0.5, wqi_threshold=0.05)
|
||||
for _ in range(25):
|
||||
wqi.feed_signal([(100.0, 1.0), (99.0, 1.0)], [(102.0, 1.0), (103.0, 1.0)], 101.0)
|
||||
bids = [(100.0, 10.0), (99.0, 5.0)]
|
||||
asks = [(102.0, 1.0)]
|
||||
signal = wqi.feed_signal(bids, asks, 101.0)
|
||||
if signal["action"] == "BUY":
|
||||
assert wqi.position != 0
|
||||
|
||||
def test_exit_on_timeout(self):
|
||||
from strategies.wqi_predictor import WQIPredictor
|
||||
import time
|
||||
wqi = WQIPredictor(z_entry=0.01, z_exit=999.0, wqi_threshold=0.01,
|
||||
max_hold_seconds=0.001)
|
||||
for _ in range(25):
|
||||
wqi.feed_signal([(100.0, 1.0), (99.0, 1.0)], [(102.0, 1.0), (103.0, 1.0)], 101.0)
|
||||
bids = [(100.0, 10.0), (99.0, 5.0)]
|
||||
asks = [(102.0, 1.0)]
|
||||
wqi.feed_signal(bids, asks, 101.0)
|
||||
time.sleep(0.01)
|
||||
signal = wqi.feed_signal(bids, asks, 101.0)
|
||||
assert signal["action"] in ("HOLD", "EXIT")
|
||||
|
||||
def test_summary_returns_zero_for_no_trades(self):
|
||||
from strategies.wqi_predictor import WQIPredictor
|
||||
wqi = WQIPredictor()
|
||||
s = wqi.summary()
|
||||
assert s["total_trades"] == 0
|
||||
|
||||
def test_reset_clears_state(self):
|
||||
from strategies.wqi_predictor import WQIPredictor
|
||||
wqi = WQIPredictor()
|
||||
bids = [(100.0, 1.0)]
|
||||
asks = [(102.0, 1.0)]
|
||||
wqi.feed_signal(bids, asks, 101.0)
|
||||
wqi.reset()
|
||||
assert wqi.position == 0
|
||||
assert len(wqi.trades) == 0
|
||||
Reference in New Issue
Block a user