""" Tests for microstructure module — book, trades, toxicity, funding, signals. """ import math import numpy as np from microstructure.book import ( microprice, mid_price, order_book_imbalance, depth_imbalance, spread_stats, depth_resiliency, queue_depletion_prob, batch_book_stats, ) from microstructure.trades import ( classify_lee_ready, classify_bulk_lee_ready, compute_markouts, markout_summary, trade_volume_profile, trade_arrival_rate, ) from microstructure.toxicity import ( compute_vpin, compute_vpin_time_series, fill_toxicity, adverse_selection_ratio, liquidation_clustering, ) from microstructure.funding import ( funding_regime, funding_predictability, funding_carry_pnl, basis_spread, basis_convergence_speed, ) from microstructure.signals import ( composite_signal, SignalPipeline, detect_hft_regime, ) # ── Helpers ──────────────────────────────────────────────── def _basic_book(): """BTC-style book.""" bids = {50000.0 - i * 10.0: 1.0 + i * 0.2 for i in range(20)} asks = {50001.0 + i * 10.0: 1.0 + i * 0.2 for i in range(20)} return bids, asks # ═══════════════════════════════════════════════════════════ # Book tests # ═══════════════════════════════════════════════════════════ class TestMidPrice: def test_simple_mid(self): bids = {100.0: 1.0} asks = {102.0: 1.0} assert mid_price(bids, asks) == 101.0 def test_empty_returns_zero(self): assert mid_price({}, {}) == 0.0 class TestMicroprice: def test_equal_depth(self): bids = {100.0: 1.0} asks = {102.0: 1.0} mid = microprice(bids, asks) assert mid == 101.0 # equal weight = simple mid def test_bid_heavy(self): bids = {100.0: 10.0} asks = {102.0: 1.0} mid = microprice(bids, asks) assert mid < 101.0 # titled toward heavier bid side → lower price def test_ask_heavy(self): bids = {100.0: 1.0} asks = {102.0: 10.0} mid = microprice(bids, asks) assert mid > 101.0 # tilted toward heavier ask side → higher price class TestOrderBookImbalance: def test_balanced(self): bids = {100.0: 5.0} asks = {102.0: 5.0} obi = order_book_imbalance(bids, asks) assert obi == 0.0 def test_bid_heavy(self): bids = {100.0: 8.0} asks = {102.0: 2.0} obi = order_book_imbalance(bids, asks) assert obi > 0 assert obi == 0.6 def test_ask_heavy(self): bids = {100.0: 2.0} asks = {102.0: 8.0} obi = order_book_imbalance(bids, asks) assert obi < 0 assert obi == -0.6 def test_empty_book(self): assert order_book_imbalance({}, {}) == 0.0 class TestSpreadStats: def test_basic(self): bids = {50000.0: 1.0} asks = {50002.0: 1.0} stats = spread_stats(bids, asks) assert stats["spread"] == 2.0 assert stats["best_bid"] == 50000.0 assert stats["best_ask"] == 50002.0 assert stats["spread_bps"] > 0 def test_empty(self): stats = spread_stats({}, {}) assert stats["spread"] == 0 class TestDepthResiliency: def test_basic(self): bids, asks = _basic_book() dr = depth_resiliency(bids, asks, impact_bps=100.0) assert dr["bid_vol"] > 0 assert dr["ask_vol"] > 0 assert dr["bid_levels"] > 0 class TestQueueDepletionProb: def test_probability_range(self): bids = {50000.0: 1.0} asks = {50002.0: 0.5} prob = queue_depletion_prob(bids, asks, level_distance=0, trade_rate_per_sec=2.0, avg_trade_size=0.1) assert 0.0 <= prob <= 1.0 def test_deep_book_low_prob(self): bids = {50000.0: 100.0} asks = {50002.0: 100.0} prob = queue_depletion_prob(bids, asks, level_distance=0, trade_rate_per_sec=1.0, avg_trade_size=0.01) assert prob < 0.01 class TestBatchBookStats: def test_multiple_snapshots(self): bids, asks = _basic_book() snaps = [{"bids": bids, "asks": asks} for _ in range(5)] result = batch_book_stats(snaps) assert result["obi"]["count"] == 5 assert result["spread_bps"]["count"] == 5 # ═══════════════════════════════════════════════════════════ # Trade tests # ═══════════════════════════════════════════════════════════ class TestLeeReady: def test_buy_above_mid(self): assert classify_lee_ready(105.0, 100.0) == "buy" def test_sell_below_mid(self): assert classify_lee_ready(95.0, 100.0) == "sell" def test_at_mid_with_ask(self): assert classify_lee_ready(100.0, 100.0, bid_at_trade=99.0, ask_at_trade=100.0) == "buy" def test_at_mid_with_bid(self): assert classify_lee_ready(100.0, 100.0, bid_at_trade=100.0, ask_at_trade=101.0) == "sell" def test_unknown_at_mid(self): assert classify_lee_ready(100.0, 100.0) == "unknown" class TestBulkLeeReady: def test_bulk_classification(self): trades = [{"px": 105}, {"px": 95}, {"px": 100}] mids = [100.0, 100.0, 100.0] bids = [99.0, 99.0, 100.0] asks = [101.0, 101.0, 101.0] sides = classify_bulk_lee_ready(trades, mids, bids, asks) assert sides == ["buy", "sell", "sell"] class TestMarkouts: def test_basic_markout(self): trades = [{"px": 100.0}, {"px": 101.0}] mids = [100.0, 100.0, 100.1, 100.2] times = [0, 100, 200, 300] result = compute_markouts(trades, mids, times, horizons_ms=[100, 200]) assert "buy" in result assert "sell" in result def test_markout_summary(self): markouts = {"buy": {100: [1.0, 2.0, -1.0]}, "sell": {100: [-1.0, -2.0]}} summary = markout_summary(markouts) assert summary["buy"][100]["count"] == 3 assert summary["sell"][100]["count"] == 2 class TestTradeVolumeProfile: def test_basic(self): trades = [{"sz": 0.1}, {"sz": 0.2}, {"sz": 0.3}] profile = trade_volume_profile(trades, n_buckets=3) assert profile["buckets"] assert sum(profile["counts"]) == 3 class TestTradeArrivalRate: def test_basic(self): times = list(range(0, 60000, 1000)) # 1 trade/sec for 60 sec result = trade_arrival_rate(times, window_sec=10) assert result["mean_rate"] > 0 # ═══════════════════════════════════════════════════════════ # Toxicity tests # ═══════════════════════════════════════════════════════════ class TestVPIN: def test_balanced_volume(self): buy_vol = [1.0] * 100 sell_vol = [1.0] * 100 result = compute_vpin(buy_vol, sell_vol) assert result["vpin_value"] >= 0 def test_imbalanced_volume(self): buy_vol = [2.0] * 200 sell_vol = [1.0] * 200 result = compute_vpin(buy_vol, sell_vol, volume_bucket_size=5.0, n_buckets=20) assert result["vpin_value"] > 0 # buy > sell → imbalance > 0 def test_empty_returns_zero(self): result = compute_vpin([], []) assert result["vpin_value"] == 0 class TestVPINTimeSeries: def test_returns_series(self): buy_vol = [1.0] * 200 + [3.0] * 50 sell_vol = [1.0] * 200 + [0.5] * 50 result = compute_vpin_time_series(buy_vol, sell_vol, n_buckets=20) assert result["vpin_values"] assert result["mean"] >= 0 assert result["max"] >= 0 class TestFillToxicity: def test_basic_toxicity(self): prices = [100.0, 101.0, 102.0, 103.0, 104.0, 105.0] mids = [100.0, 100.5, 101.0, 101.5, 102.0, 102.5] sides = ["buy", "buy", "sell", "sell", "buy", "sell"] result = fill_toxicity(prices, mids, sides, horizon_ticks=2) assert "overall_bps" in result class TestAdverseSelection: def test_basic(self): mids_before = [100.0, 100.0, 100.0] mids_after = [100.5, 99.5, 100.0] sides = ["buy", "sell", "buy"] result = adverse_selection_ratio(mids_after, mids_before, sides) assert result["buy_adverse_bps"] != 0 or result["sell_adverse_bps"] != 0 class TestLiquidationClustering: def test_no_clusters(self): times = list(range(0, 60000, 5000)) # spaced 5 sec apart result = liquidation_clustering(times) assert result["n_clusters"] == 0 def test_tight_clusters(self): times = [0, 100, 200, 300, 400, 50000, 50100, 50200, 50300] result = liquidation_clustering(times, window_sec=300) assert result["n_clusters"] >= 0 # may or may not cluster depending on mean interval # ═══════════════════════════════════════════════════════════ # Funding tests # ═══════════════════════════════════════════════════════════ class TestFundingRegime: def test_neutral(self): rates = [0.000001] * 2000 # ~0.1% annual result = funding_regime(rates, window_hours=24, n_samples_per_hour=60) assert result["regime"] == "neutral" def test_high_positive(self): rates = [0.0001] * 2000 # ~11% annual result = funding_regime(rates, window_hours=24, n_samples_per_hour=60) assert result["regime"] in ("positive", "high_positive") class TestFundingPredictability: def test_random(self): np.random.seed(42) rates = list(np.random.normal(0, 0.001, 100)) result = funding_predictability(rates) assert len(result["autocorr"]) == 3 class TestBasisSpread: def test_premium(self): perp = [105.0, 106.0, 107.0] spot = [100.0, 101.0, 102.0] result = basis_spread(perp, spot) assert result["current_basis_bps"] > 0 assert result["mean_basis_bps"] > 0 class TestBasisConvergence: def test_mean_reverting(self): basis = [50.0, 45.0, 40.0, 35.0, 30.0, 25.0] * 100 # decaying result = basis_convergence_speed(basis) assert result["ar1_coef"] > 0 # ═══════════════════════════════════════════════════════════ # Signals tests # ═══════════════════════════════════════════════════════════ class TestCompositeSignal: def test_strong_buy(self): result = composite_signal(obi=0.5, trade_imbalance=0.3, vpin=0.1, funding_regime="negative") assert result["signal"] == "buy" assert result["confidence"] > 0 def test_strong_sell(self): result = composite_signal(obi=-0.5, trade_imbalance=-0.3, vpin=0.1, funding_regime="high_positive") assert result["signal"] == "sell" assert result["confidence"] > 0 def test_neutral(self): result = composite_signal(obi=0.05, trade_imbalance=0.0, vpin=0.0) assert result["signal"] == "neutral" assert result["confidence"] >= 0 def test_toxic_reduces_confidence(self): norm = composite_signal(obi=0.4, trade_imbalance=0.3, vpin=0.1) toxic = composite_signal(obi=0.4, trade_imbalance=0.3, vpin=0.8) assert toxic["confidence"] < norm["confidence"] def test_wide_spread_reduces_confidence(self): tight = composite_signal(obi=0.4, trade_imbalance=0.3, spread_bps=1.0) wide = composite_signal(obi=0.4, trade_imbalance=0.3, spread_bps=30.0) assert wide["confidence"] < tight["confidence"] class TestDetectHFTRegime: def test_toxic(self): assert detect_hft_regime(obi_std=0.1, spread_mean_bps=15.0, trade_rate_per_sec=1.0, vpin=0.5) == "toxic" def test_quiet(self): assert detect_hft_regime(obi_std=0.1, spread_mean_bps=2.0, trade_rate_per_sec=0.05, vpin=0.1) == "quiet" def test_trending(self): assert detect_hft_regime(obi_std=0.4, spread_mean_bps=3.0, trade_rate_per_sec=2.0, vpin=0.1) == "trending" def test_ranging(self): assert detect_hft_regime(obi_std=0.1, spread_mean_bps=2.0, trade_rate_per_sec=2.0, vpin=0.1) == "ranging" class TestSignalPipeline: def test_emit_no_data(self): p = SignalPipeline() result = p.emit() assert "signal" in result assert result["signal"] == "neutral" def test_update_and_emit(self): p = SignalPipeline(obi_window=10, trade_window=10, vpin_volume_size=1.0, vpin_buckets=5) bids = {100.0: 5.0} asks = {102.0: 1.0} p.update_book(bids, asks) for _ in range(3): p.update_trade(101.5, 0.1, 101.0) for _ in range(1): p.update_trade(100.5, 0.1, 101.0) result = p.emit() assert result["signal"] in ("buy", "sell", "neutral")