fcfc136384
New microstructure/ module with pure-function analytics: microstructure/book.py: microprice() — depth-weighted mid price mid_price() — simple bid/ask midpoint order_book_imbalance() — ranged [-1, 1] volume skew depth_imbalance() — imbalance at fixed price distance spread_stats() — spread, spread_bps, mid, bid, ask depth_resiliency() — bid/ask volume within impact radius queue_depletion_prob() — Poisson fill probability at level batch_book_stats() — aggregate stats across snapshots microstructure/trades.py: classify_lee_ready() — Lee-Ready aggressor classification classify_bulk_lee_ready() — batch classification with mids/bids/asks compute_markouts() — forward mid-price change at configurable horizons markout_summary() — mean/std/t-stat per side per horizon trade_volume_profile() — size bucket distribution trade_arrival_rate() — rolling trades/sec with burst detection microstructure/toxicity.py: compute_vpin() — volume-synchronized informed trading probability compute_vpin_time_series() — rolling VPIN with alarm threshold fill_toxicity() — adverse price movement post-trade adverse_selection_ratio() — per-side adverse selection liquidation_clustering() — cluster detection in liquidation events microstructure/funding.py: funding_regime() — classify regime (neutral/positive/negative/high) funding_predictability() — AR(1) autocorrelation analysis funding_carry_pnl() — cumulative carry PnL estimation basis_spread() — perp premium over spot (bps) basis_convergence_speed() — mean-reversion half-life via AR(1) microstructure/signals.py: composite_signal() — weighted OBI + trade + VPIN + funding signal SignalPipeline — stateful pipeline accumulating book/trade updates detect_hft_regime() — regime classifier for HFT strategy selection Bug fixes in Phase 1: - data/latency.py: proper linear-interpolation percentiles - data/normalizer.py: UTC timezone for naive datetimes - data/normalizer.py: detect_sequence_gap returns gap-1 (missing count) - microstructure/toxicity.py: consistent vpin_value key in compute_vpin 81 tests across 4 test files (store, normalizer, latency, microstructure)
389 lines
14 KiB
Python
389 lines
14 KiB
Python
"""
|
|
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")
|