Files
ftdt-quant-lab/tests/test_microstructure.py
ramseshk fcfc136384 feat: Phase 2 — microstructure analytics + 81 tests
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)
2026-08-07 14:34:18 +08:00

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")