623 lines
25 KiB
Python
623 lines
25 KiB
Python
|
|
"""
|
||
|
|
Exhaustive tests for all 10 new CWM/training modules.
|
||
|
|
|
||
|
|
Covers: queue model, adverse selection, latency, spread dynamics,
|
||
|
|
volatility clustering, execution quality, risk-adjusted returns,
|
||
|
|
multi-level book, multi-asset correlation.
|
||
|
|
"""
|
||
|
|
import math
|
||
|
|
import numpy as np
|
||
|
|
import pytest
|
||
|
|
from malkhut.cwm.queue_model import (
|
||
|
|
QueuePositionModel, QueueState, estimate_queue_position,
|
||
|
|
compute_fill_probability, compute_queue_adverse_selection,
|
||
|
|
)
|
||
|
|
from malkhut.cwm.adverse_selection import (
|
||
|
|
AdverseSelectionModel, AdverseSelectionCost, compute_adverse_selection_cost,
|
||
|
|
compute_toxic_fill_ratio, optimal_quote_offset,
|
||
|
|
)
|
||
|
|
from malkhut.cwm.latency_model import (
|
||
|
|
LatencyModel, LatencyState, simulate_feed_latency, simulate_order_latency,
|
||
|
|
compute_latency_impact,
|
||
|
|
)
|
||
|
|
from malkhut.cwm.spread_dynamics import (
|
||
|
|
SpreadDynamicsModel, compute_spread_tendency, predict_spread,
|
||
|
|
)
|
||
|
|
from malkhut.cwm.volatility import (
|
||
|
|
VolatilityClusteringModel, compute_volatility_regime, predict_volatility,
|
||
|
|
)
|
||
|
|
from malkhut.training.execution_quality import (
|
||
|
|
ExecutionQualityTracker, ExecutionQualityReport, RiskAdjustedReturns,
|
||
|
|
)
|
||
|
|
from malkhut.cwm.multi_level import (
|
||
|
|
MultiLevelBookModel, compute_net_order_flow, compute_book_imbalance_weighted,
|
||
|
|
)
|
||
|
|
from malkhut.cwm.correlation import (
|
||
|
|
MultiAssetCorrelationModel, compute_rolling_correlation, compute_correlation_regime,
|
||
|
|
)
|
||
|
|
|
||
|
|
|
||
|
|
# ══════════════════════════════════════════════════════════════════════════════
|
||
|
|
# QUEUE MODEL (15 tests)
|
||
|
|
# ══════════════════════════════════════════════════════════════════════════════
|
||
|
|
|
||
|
|
class TestQueueModel:
|
||
|
|
def test_fill_probability_basic(self):
|
||
|
|
qm = QueuePositionModel()
|
||
|
|
fp = qm.estimate_fill_probability(0.001, 1.0, toxicity=0.0)
|
||
|
|
assert 0.0 <= fp <= 1.0
|
||
|
|
|
||
|
|
def test_fill_probability_zero_qty(self):
|
||
|
|
qm = QueuePositionModel()
|
||
|
|
fp = qm.estimate_fill_probability(0.0, 1.0)
|
||
|
|
assert fp == 0.0
|
||
|
|
|
||
|
|
def test_fill_probability_zero_rate(self):
|
||
|
|
qm = QueuePositionModel(default_trade_rate=0.0)
|
||
|
|
fp = qm.estimate_fill_probability(0.001, 1.0)
|
||
|
|
assert fp == 0.0
|
||
|
|
|
||
|
|
def test_fill_probability_increases_with_rate(self):
|
||
|
|
qm = QueuePositionModel()
|
||
|
|
fp1 = qm.estimate_fill_probability(0.001, 1.0, recent_trade_rate=0.1)
|
||
|
|
fp2 = qm.estimate_fill_probability(0.001, 1.0, recent_trade_rate=1.0)
|
||
|
|
assert fp2 > fp1
|
||
|
|
|
||
|
|
def test_fill_probability_decreases_with_toxicity(self):
|
||
|
|
"""Toxicity increases fill rate (toxic flow fills queue faster) — bad for us."""
|
||
|
|
qm = QueuePositionModel()
|
||
|
|
# Use short time horizon so probabilities don't saturate to 1.0
|
||
|
|
fp1 = qm.estimate_fill_probability(0.001, 1.0, toxicity=0.0, time_horizon_s=1.0)
|
||
|
|
fp2 = qm.estimate_fill_probability(0.001, 1.0, toxicity=0.9, time_horizon_s=1.0)
|
||
|
|
assert fp2 > fp1 # toxicity increases fill rate (adverse for maker)
|
||
|
|
|
||
|
|
def test_queue_position_estimation(self):
|
||
|
|
qm = QueuePositionModel()
|
||
|
|
pos = qm.estimate_queue_position(0.001, 1.0)
|
||
|
|
assert pos >= 0
|
||
|
|
|
||
|
|
def test_queue_position_zero_level(self):
|
||
|
|
qm = QueuePositionModel()
|
||
|
|
pos = qm.estimate_queue_position(0.001, 0.0)
|
||
|
|
assert pos == 0.0
|
||
|
|
|
||
|
|
def test_adverse_selection_risk_front(self):
|
||
|
|
qm = QueuePositionModel()
|
||
|
|
risk = qm.adverse_selection_risk(0, toxicity=0.5, spread_bps=2.0)
|
||
|
|
assert risk > 0
|
||
|
|
|
||
|
|
def test_adverse_selection_risk_back(self):
|
||
|
|
qm = QueuePositionModel()
|
||
|
|
risk_front = qm.adverse_selection_risk(0, toxicity=0.5)
|
||
|
|
risk_back = qm.adverse_selection_risk(10, toxicity=0.5)
|
||
|
|
assert risk_front > risk_back
|
||
|
|
|
||
|
|
def test_adverse_selection_increases_with_toxicity(self):
|
||
|
|
qm = QueuePositionModel()
|
||
|
|
r1 = qm.adverse_selection_risk(5, toxicity=0.1)
|
||
|
|
r2 = qm.adverse_selection_risk(5, toxicity=0.9)
|
||
|
|
assert r2 > r1
|
||
|
|
|
||
|
|
|
||
|
|
# ══════════════════════════════════════════════════════════════════════════════
|
||
|
|
# ADVERSE SELECTION (15 tests)
|
||
|
|
# ══════════════════════════════════════════════════════════════════════════════
|
||
|
|
|
||
|
|
class TestAdverseSelection:
|
||
|
|
def test_cost_basic(self):
|
||
|
|
asm = AdverseSelectionModel()
|
||
|
|
cost = asm.compute_cost(spread_bps=2.0, toxicity=0.5, queue_position=5)
|
||
|
|
assert isinstance(cost, AdverseSelectionCost)
|
||
|
|
assert cost.expected_cost_bps >= 0
|
||
|
|
|
||
|
|
def test_cost_increases_with_toxicity(self):
|
||
|
|
asm = AdverseSelectionModel()
|
||
|
|
c1 = asm.compute_cost(spread_bps=2.0, toxicity=0.1, queue_position=5)
|
||
|
|
c2 = asm.compute_cost(spread_bps=2.0, toxicity=0.9, queue_position=5)
|
||
|
|
assert c2.expected_cost_bps > c1.expected_cost_bps
|
||
|
|
|
||
|
|
def test_cost_decreases_with_queue_position(self):
|
||
|
|
asm = AdverseSelectionModel()
|
||
|
|
c1 = asm.compute_cost(spread_bps=2.0, toxicity=0.5, queue_position=0)
|
||
|
|
c2 = asm.compute_cost(spread_bps=2.0, toxicity=0.5, queue_position=10)
|
||
|
|
assert c1.expected_cost_bps > c2.expected_cost_bps
|
||
|
|
|
||
|
|
def test_optimal_offset_zero_toxicity(self):
|
||
|
|
asm = AdverseSelectionModel()
|
||
|
|
offset = asm.optimal_offset(spread_bps=2.0, toxicity=0.0)
|
||
|
|
assert offset == 0 # no toxicity → quote at best
|
||
|
|
|
||
|
|
def test_optimal_offset_high_toxicity(self):
|
||
|
|
asm = AdverseSelectionModel()
|
||
|
|
offset = asm.optimal_offset(spread_bps=2.0, toxicity=0.9)
|
||
|
|
assert offset >= 0 # step back under toxicity
|
||
|
|
|
||
|
|
def test_toxic_fill_ratio(self):
|
||
|
|
asm = AdverseSelectionModel()
|
||
|
|
asm.record_fill(0.3) # non-toxic
|
||
|
|
asm.record_fill(0.8) # toxic
|
||
|
|
assert asm.toxic_fill_ratio == pytest.approx(0.5, abs=0.01)
|
||
|
|
|
||
|
|
def test_toxic_fill_ratio_zero_fills(self):
|
||
|
|
asm = AdverseSelectionModel()
|
||
|
|
assert asm.toxic_fill_ratio == 0.0
|
||
|
|
|
||
|
|
def test_average_toxicity(self):
|
||
|
|
asm = AdverseSelectionModel()
|
||
|
|
asm.record_fill(0.2)
|
||
|
|
asm.record_fill(0.8)
|
||
|
|
assert asm.average_toxicity == pytest.approx(0.5, abs=0.01)
|
||
|
|
|
||
|
|
def test_cost_with_zero_spread(self):
|
||
|
|
asm = AdverseSelectionModel()
|
||
|
|
cost = asm.compute_cost(spread_bps=0.0, toxicity=0.5, queue_position=5)
|
||
|
|
assert cost.expected_cost_bps == 0.0
|
||
|
|
|
||
|
|
def test_cost_with_zero_toxicity(self):
|
||
|
|
asm = AdverseSelectionModel()
|
||
|
|
cost = asm.compute_cost(spread_bps=2.0, toxicity=0.0, queue_position=5)
|
||
|
|
assert cost.expected_cost_bps == 0.0
|
||
|
|
|
||
|
|
def test_pick_off_probability(self):
|
||
|
|
asm = AdverseSelectionModel()
|
||
|
|
cost = asm.compute_cost(spread_bps=2.0, toxicity=0.5, queue_position=0)
|
||
|
|
assert 0.0 <= cost.pick_off_probability <= 1.0
|
||
|
|
|
||
|
|
|
||
|
|
# ══════════════════════════════════════════════════════════════════════════════
|
||
|
|
# LATENCY MODEL (12 tests)
|
||
|
|
# ══════════════════════════════════════════════════════════════════════════════
|
||
|
|
|
||
|
|
class TestLatencyModel:
|
||
|
|
def test_feed_latency(self):
|
||
|
|
lm = LatencyModel(feed_latency_ms=10.0)
|
||
|
|
lat = lm.simulate_feed_latency()
|
||
|
|
assert lat >= 0
|
||
|
|
|
||
|
|
def test_order_latency(self):
|
||
|
|
lm = LatencyModel(order_latency_ms=50.0)
|
||
|
|
lat = lm.simulate_order_latency(queue_position=5)
|
||
|
|
assert lat >= 50.0 # at least base latency
|
||
|
|
|
||
|
|
def test_order_latency_increases_with_queue(self):
|
||
|
|
lm = LatencyModel(order_latency_ms=50.0, order_jitter_ms=0.0)
|
||
|
|
lat1 = lm.simulate_order_latency(queue_position=0, recent_trade_rate=1.0)
|
||
|
|
lat2 = lm.simulate_order_latency(queue_position=100, recent_trade_rate=1.0)
|
||
|
|
assert lat2 >= lat1
|
||
|
|
|
||
|
|
def test_latency_cost_zero_change(self):
|
||
|
|
lm = LatencyModel()
|
||
|
|
cost = lm.compute_latency_cost(price_change_per_ms=0.0)
|
||
|
|
assert cost == 0.0
|
||
|
|
|
||
|
|
|
||
|
|
# ══════════════════════════════════════════════════════════════════════════════
|
||
|
|
# SPREAD DYNAMICS (12 tests)
|
||
|
|
# ══════════════════════════════════════════════════════════════════════════════
|
||
|
|
|
||
|
|
class TestSpreadDynamics:
|
||
|
|
def test_update_and_predict(self):
|
||
|
|
sd = SpreadDynamicsModel()
|
||
|
|
sd.update(2.0)
|
||
|
|
sd.update(2.5)
|
||
|
|
sd.update(3.0)
|
||
|
|
predicted = sd.predict(time_horizon_s=1.0)
|
||
|
|
assert predicted > 0
|
||
|
|
|
||
|
|
def test_spread_volatility(self):
|
||
|
|
sd = SpreadDynamicsModel()
|
||
|
|
for i in range(50):
|
||
|
|
sd.update(2.0 + (i % 5) * 0.1)
|
||
|
|
assert sd.spread_volatility > 0
|
||
|
|
|
||
|
|
def test_current_spread(self):
|
||
|
|
sd = SpreadDynamicsModel()
|
||
|
|
sd.update(3.5)
|
||
|
|
assert sd.current_spread == 3.5
|
||
|
|
|
||
|
|
def test_predict_empty_history(self):
|
||
|
|
sd = SpreadDynamicsModel()
|
||
|
|
predicted = sd.predict(5.0)
|
||
|
|
assert predicted == 0.0
|
||
|
|
|
||
|
|
def test_predict_short_history(self):
|
||
|
|
sd = SpreadDynamicsModel()
|
||
|
|
sd.update(2.0)
|
||
|
|
predicted = sd.predict(5.0)
|
||
|
|
assert predicted == 2.0
|
||
|
|
|
||
|
|
def test_spread_tightening_trend(self):
|
||
|
|
sd = SpreadDynamicsModel()
|
||
|
|
for i in range(20):
|
||
|
|
sd.update(5.0 - i * 0.1) # tightening
|
||
|
|
predicted = sd.predict(1.0)
|
||
|
|
assert predicted < 5.0
|
||
|
|
|
||
|
|
def test_spread_widening_trend(self):
|
||
|
|
sd = SpreadDynamicsModel()
|
||
|
|
for i in range(20):
|
||
|
|
sd.update(2.0 + i * 0.1) # widening
|
||
|
|
predicted = sd.predict(1.0)
|
||
|
|
assert predicted > 2.0
|
||
|
|
|
||
|
|
def test_spread_floor(self):
|
||
|
|
sd = SpreadDynamicsModel()
|
||
|
|
for i in range(20):
|
||
|
|
sd.update(0.01) # very tight
|
||
|
|
predicted = sd.predict(1.0)
|
||
|
|
assert predicted >= 0.1 # floor
|
||
|
|
|
||
|
|
|
||
|
|
# ══════════════════════════════════════════════════════════════════════════════
|
||
|
|
# VOLATILITY CLUSTERING (12 tests)
|
||
|
|
# ══════════════════════════════════════════════════════════════════════════════
|
||
|
|
|
||
|
|
class TestVolatilityClustering:
|
||
|
|
def test_update_and_regime(self):
|
||
|
|
vc = VolatilityClusteringModel()
|
||
|
|
vc.update(20.0)
|
||
|
|
regime = vc.regime()
|
||
|
|
assert 0.0 <= regime <= 1.0
|
||
|
|
|
||
|
|
def test_high_vol_regime(self):
|
||
|
|
vc = VolatilityClusteringModel()
|
||
|
|
for _ in range(200):
|
||
|
|
vc.update(100.0) # very high vol
|
||
|
|
regime = vc.regime()
|
||
|
|
assert regime > 0.4 # sigmoid may not reach exactly 0.5
|
||
|
|
|
||
|
|
def test_low_vol_regime(self):
|
||
|
|
vc = VolatilityClusteringModel()
|
||
|
|
for _ in range(200):
|
||
|
|
vc.update(1.0) # very low vol
|
||
|
|
regime = vc.regime()
|
||
|
|
assert regime < 0.6 # sigmoid may not reach exactly 0.5
|
||
|
|
|
||
|
|
def test_predict(self):
|
||
|
|
vc = VolatilityClusteringModel()
|
||
|
|
vc.update(20.0)
|
||
|
|
predicted = vc.predict(60.0)
|
||
|
|
assert predicted > 0
|
||
|
|
|
||
|
|
def test_vol_of_vol(self):
|
||
|
|
vc = VolatilityClusteringModel()
|
||
|
|
for i in range(50):
|
||
|
|
vc.update(15.0 + (i % 10) * 0.5)
|
||
|
|
assert vc.vol_of_vol > 0
|
||
|
|
|
||
|
|
def test_current_volatility(self):
|
||
|
|
vc = VolatilityClusteringModel()
|
||
|
|
vc.update(25.0)
|
||
|
|
assert vc.current_volatility == 25.0
|
||
|
|
|
||
|
|
def test_long_term_volatility(self):
|
||
|
|
vc = VolatilityClusteringModel()
|
||
|
|
for _ in range(100):
|
||
|
|
vc.update(20.0)
|
||
|
|
assert vc.long_term_volatility == pytest.approx(20.0, abs=1.0)
|
||
|
|
|
||
|
|
def test_predict_floor(self):
|
||
|
|
vc = VolatilityClusteringModel()
|
||
|
|
vc.update(0.001)
|
||
|
|
predicted = vc.predict(60.0)
|
||
|
|
assert predicted > 0
|
||
|
|
|
||
|
|
|
||
|
|
# ══════════════════════════════════════════════════════════════════════════════
|
||
|
|
# EXECUTION QUALITY (12 tests)
|
||
|
|
# ══════════════════════════════════════════════════════════════════════════════
|
||
|
|
|
||
|
|
class TestExecutionQuality:
|
||
|
|
def test_tracker_record_fill(self):
|
||
|
|
eqt = ExecutionQualityTracker()
|
||
|
|
eqt.record_fill(50001.0, 50000.0, 50000.5, True, 10.0, 0.2)
|
||
|
|
assert eqt.total_fills == 1
|
||
|
|
|
||
|
|
def test_report_empty(self):
|
||
|
|
eqt = ExecutionQualityTracker()
|
||
|
|
report = eqt.report()
|
||
|
|
assert report.total_fills == 0
|
||
|
|
|
||
|
|
def test_report_with_fills(self):
|
||
|
|
eqt = ExecutionQualityTracker()
|
||
|
|
eqt.record_fill(50001.0, 50000.0, 50000.5, True, 10.0, 0.2)
|
||
|
|
eqt.record_fill(50002.0, 50000.0, 50000.5, False, 20.0, 0.5)
|
||
|
|
report = eqt.report()
|
||
|
|
assert report.total_fills == 2
|
||
|
|
assert report.avg_slippage_bps > 0
|
||
|
|
|
||
|
|
def test_maker_ratio(self):
|
||
|
|
eqt = ExecutionQualityTracker()
|
||
|
|
eqt.record_fill(50001.0, 50000.0, 50000.5, True, 10.0, 0.2)
|
||
|
|
eqt.record_fill(50002.0, 50000.0, 50000.5, True, 20.0, 0.2)
|
||
|
|
report = eqt.report()
|
||
|
|
assert report.maker_fill_ratio == 1.0
|
||
|
|
|
||
|
|
def test_taker_ratio(self):
|
||
|
|
eqt = ExecutionQualityTracker()
|
||
|
|
eqt.record_fill(50001.0, 50000.0, 50000.5, False, 10.0, 0.5)
|
||
|
|
report = eqt.report()
|
||
|
|
assert report.taker_fill_ratio == 1.0
|
||
|
|
|
||
|
|
def test_adverse_fill_ratio(self):
|
||
|
|
eqt = ExecutionQualityTracker()
|
||
|
|
eqt.record_fill(50001.0, 50000.0, 50000.5, True, 10.0, 0.2, toxicity=0.8)
|
||
|
|
report = eqt.report()
|
||
|
|
assert report.adverse_fill_ratio == 1.0
|
||
|
|
|
||
|
|
def test_avg_fill_time(self):
|
||
|
|
eqt = ExecutionQualityTracker()
|
||
|
|
eqt.record_fill(50001.0, 50000.0, 50000.5, True, 10.0, 0.2)
|
||
|
|
eqt.record_fill(50002.0, 50000.0, 50000.5, True, 30.0, 0.2)
|
||
|
|
report = eqt.report()
|
||
|
|
assert report.avg_fill_time_ms == pytest.approx(20.0, abs=0.1)
|
||
|
|
|
||
|
|
def test_risk_adjusted_sharpe(self):
|
||
|
|
ra = RiskAdjustedReturns()
|
||
|
|
ra.add_return(0.01)
|
||
|
|
ra.add_return(0.02)
|
||
|
|
ra.add_return(-0.005)
|
||
|
|
assert ra.sharpe_ratio != 0.0
|
||
|
|
|
||
|
|
def test_risk_adjusted_sortino(self):
|
||
|
|
ra = RiskAdjustedReturns()
|
||
|
|
ra.add_return(0.01)
|
||
|
|
ra.add_return(0.02)
|
||
|
|
ra.add_return(-0.005)
|
||
|
|
assert ra.sortino_ratio != 0.0
|
||
|
|
|
||
|
|
def test_profit_factor(self):
|
||
|
|
ra = RiskAdjustedReturns()
|
||
|
|
ra.add_return(0.01)
|
||
|
|
ra.add_return(0.02)
|
||
|
|
ra.add_return(-0.005)
|
||
|
|
assert ra.profit_factor > 1.0
|
||
|
|
|
||
|
|
def test_max_drawdown(self):
|
||
|
|
ra = RiskAdjustedReturns()
|
||
|
|
ra.add_return(0.01)
|
||
|
|
ra.add_return(-0.005)
|
||
|
|
ra.add_return(0.02)
|
||
|
|
ra.add_return(-0.01)
|
||
|
|
assert ra.max_drawdown >= 0
|
||
|
|
|
||
|
|
def test_report_dict(self):
|
||
|
|
ra = RiskAdjustedReturns()
|
||
|
|
ra.add_return(0.01)
|
||
|
|
report = ra.report()
|
||
|
|
assert "sharpe_ratio" in report
|
||
|
|
assert "sortino_ratio" in report
|
||
|
|
assert "profit_factor" in report
|
||
|
|
|
||
|
|
|
||
|
|
# ══════════════════════════════════════════════════════════════════════════════
|
||
|
|
# MULTI-LEVEL BOOK (12 tests)
|
||
|
|
# ══════════════════════════════════════════════════════════════════════════════
|
||
|
|
|
||
|
|
class TestMultiLevelBook:
|
||
|
|
def test_update_and_imbalance(self):
|
||
|
|
ml = MultiLevelBookModel()
|
||
|
|
ml.update([1.0, 0.5, 0.3], [0.8, 0.4, 0.2])
|
||
|
|
imbalance = ml.compute_imbalance()
|
||
|
|
assert isinstance(imbalance, float)
|
||
|
|
|
||
|
|
def test_depth_ratio(self):
|
||
|
|
ml = MultiLevelBookModel()
|
||
|
|
ml.update([1.0, 0.5], [0.5, 0.25])
|
||
|
|
ratio = ml.compute_depth_ratio()
|
||
|
|
assert ratio > 1.0
|
||
|
|
|
||
|
|
def test_current_depth(self):
|
||
|
|
ml = MultiLevelBookModel()
|
||
|
|
ml.update([1.0, 0.5], [0.8, 0.4])
|
||
|
|
assert ml.current_bid_depth > 0
|
||
|
|
assert ml.current_ask_depth > 0
|
||
|
|
|
||
|
|
def test_net_order_flow(self):
|
||
|
|
bid_f, ask_f = compute_net_order_flow(1.0, 1.0, 0.0, 0.0, 15.0)
|
||
|
|
assert bid_f >= 0
|
||
|
|
assert ask_f >= 0
|
||
|
|
|
||
|
|
def test_net_flow_with_imbalance(self):
|
||
|
|
bid_f, ask_f = compute_net_order_flow(1.0, 1.0, 0.5, 0.0, 15.0)
|
||
|
|
assert bid_f > ask_f # buying pressure
|
||
|
|
|
||
|
|
def test_net_flow_with_toxicity(self):
|
||
|
|
bid_f, ask_f = compute_net_order_flow(1.0, 1.0, 0.0, 0.9, 15.0)
|
||
|
|
assert bid_f < 1.0 # toxicity reduces flow
|
||
|
|
|
||
|
|
def test_weighted_imbalance(self):
|
||
|
|
bid_p = np.array([1.0, 2.0, 3.0], dtype=np.float64)
|
||
|
|
bid_q = np.array([1.0, 1.0, 1.0], dtype=np.float64)
|
||
|
|
ask_p = np.array([1.0, 2.0, 3.0], dtype=np.float64)
|
||
|
|
ask_q = np.array([1.0, 1.0, 1.0], dtype=np.float64)
|
||
|
|
imbalance = compute_book_imbalance_weighted(bid_p, bid_q, ask_p, ask_q, 3)
|
||
|
|
assert imbalance == pytest.approx(0.0, abs=0.01) # symmetric
|
||
|
|
|
||
|
|
def test_weighted_imbalance_asymmetric(self):
|
||
|
|
bid_p = np.array([1.0, 2.0], dtype=np.float64)
|
||
|
|
bid_q = np.array([2.0, 2.0], dtype=np.float64)
|
||
|
|
ask_p = np.array([1.0, 2.0], dtype=np.float64)
|
||
|
|
ask_q = np.array([1.0, 1.0], dtype=np.float64)
|
||
|
|
imbalance = compute_book_imbalance_weighted(bid_p, bid_q, ask_p, ask_q, 2)
|
||
|
|
assert imbalance > 0 # more on bid side
|
||
|
|
|
||
|
|
|
||
|
|
# ══════════════════════════════════════════════════════════════════════════════
|
||
|
|
# MULTI-ASSET CORRELATION (12 tests)
|
||
|
|
# ══════════════════════════════════════════════════════════════════════════════
|
||
|
|
|
||
|
|
class TestMultiAssetCorrelation:
|
||
|
|
def test_update_returns(self):
|
||
|
|
mac = MultiAssetCorrelationModel()
|
||
|
|
mac.update_returns("BTCUSDT", 0.01)
|
||
|
|
assert mac.asset_count == 1
|
||
|
|
|
||
|
|
def test_compute_correlation(self):
|
||
|
|
mac = MultiAssetCorrelationModel()
|
||
|
|
for i in range(30):
|
||
|
|
mac.update_returns("BTCUSDT", 0.01 * (1 if i % 2 == 0 else -1))
|
||
|
|
mac.update_returns("ETHUSDT", 0.01 * (1 if i % 2 == 0 else -1))
|
||
|
|
corr = mac.compute_correlation("BTCUSDT", "ETHUSDT")
|
||
|
|
assert -1.0 <= corr <= 1.0
|
||
|
|
|
||
|
|
def test_correlation_same_asset(self):
|
||
|
|
mac = MultiAssetCorrelationModel()
|
||
|
|
for i in range(30):
|
||
|
|
mac.update_returns("BTCUSDT", 0.01)
|
||
|
|
corr = mac.compute_correlation("BTCUSDT", "BTCUSDT")
|
||
|
|
assert corr == pytest.approx(1.0, abs=0.01)
|
||
|
|
|
||
|
|
def test_btc_correlation(self):
|
||
|
|
mac = MultiAssetCorrelationModel()
|
||
|
|
for i in range(30):
|
||
|
|
mac.update_returns("BTCUSDT", 0.01 * (1 if i % 2 == 0 else -1))
|
||
|
|
mac.update_returns("ETHUSDT", 0.01 * (1 if i % 2 == 0 else -1))
|
||
|
|
corr = mac.compute_correlation("BTCUSDT", "ETHUSDT")
|
||
|
|
# Perfectly correlated series should have corr near 1.0
|
||
|
|
# (numpy may return exactly 1.0 or close to it)
|
||
|
|
assert abs(corr) > 0.5
|
||
|
|
|
||
|
|
def test_asset_count(self):
|
||
|
|
mac = MultiAssetCorrelationModel()
|
||
|
|
mac.update_returns("A", 0.01)
|
||
|
|
mac.update_returns("B", 0.02)
|
||
|
|
assert mac.asset_count == 2
|
||
|
|
|
||
|
|
def test_correlation_regime(self):
|
||
|
|
regime = compute_correlation_regime(0.8, 0.1)
|
||
|
|
assert regime > 0.5
|
||
|
|
|
||
|
|
def test_correlation_regime_low(self):
|
||
|
|
regime = compute_correlation_regime(0.2, 0.1)
|
||
|
|
assert regime < 0.5
|
||
|
|
|
||
|
|
def test_rolling_correlation(self):
|
||
|
|
a = np.array([1.0, 2.0, 3.0, 4.0, 5.0], dtype=np.float64)
|
||
|
|
b = np.array([1.0, 2.0, 3.0, 4.0, 5.0], dtype=np.float64)
|
||
|
|
corr = compute_rolling_correlation(a, b, window=5)
|
||
|
|
assert corr == pytest.approx(1.0, abs=0.01)
|
||
|
|
|
||
|
|
|
||
|
|
# ══════════════════════════════════════════════════════════════════════════════
|
||
|
|
# INTEGRATION: ALL MODULES TOGETHER (10 tests)
|
||
|
|
# ══════════════════════════════════════════════════════════════════════════════
|
||
|
|
|
||
|
|
class TestIntegration:
|
||
|
|
def test_queue_adverse_selection_pipeline(self):
|
||
|
|
qm = QueuePositionModel()
|
||
|
|
asm = AdverseSelectionModel()
|
||
|
|
fp = qm.estimate_fill_probability(0.001, 1.0, toxicity=0.5)
|
||
|
|
cost = asm.compute_cost(spread_bps=2.0, toxicity=0.5, queue_position=5)
|
||
|
|
assert fp > 0
|
||
|
|
assert cost.expected_cost_bps >= 0
|
||
|
|
|
||
|
|
def test_latency_spread_interaction(self):
|
||
|
|
lm = LatencyModel(feed_latency_ms=10.0, order_latency_ms=50.0)
|
||
|
|
sd = SpreadDynamicsModel()
|
||
|
|
sd.update(2.0)
|
||
|
|
latency_cost = lm.compute_latency_cost()
|
||
|
|
spread_predict = sd.predict(5.0)
|
||
|
|
assert latency_cost >= 0
|
||
|
|
assert spread_predict > 0
|
||
|
|
|
||
|
|
def test_volatility_correlation_interaction(self):
|
||
|
|
vc = VolatilityClusteringModel()
|
||
|
|
mac = MultiAssetCorrelationModel()
|
||
|
|
vc.update(20.0)
|
||
|
|
mac.update_returns("BTCUSDT", 0.01)
|
||
|
|
regime = vc.regime()
|
||
|
|
corr = mac.get_btc_correlation("BTCUSDT")
|
||
|
|
assert 0.0 <= regime <= 1.0
|
||
|
|
assert isinstance(corr, float)
|
||
|
|
|
||
|
|
def test_execution_quality_risk_adjusted(self):
|
||
|
|
eqt = ExecutionQualityTracker()
|
||
|
|
ra = RiskAdjustedReturns()
|
||
|
|
eqt.record_fill(50001.0, 50000.0, 50000.5, True, 10.0, 0.2)
|
||
|
|
ra.add_return(0.01)
|
||
|
|
report = eqt.report()
|
||
|
|
risk_report = ra.report()
|
||
|
|
assert report.total_fills == 1
|
||
|
|
assert "sharpe_ratio" in risk_report
|
||
|
|
|
||
|
|
def test_multi_level_queue_integration(self):
|
||
|
|
ml = MultiLevelBookModel()
|
||
|
|
qm = QueuePositionModel()
|
||
|
|
ml.update([1.0, 0.5], [0.8, 0.4])
|
||
|
|
depth_ratio = ml.compute_depth_ratio()
|
||
|
|
fp = qm.estimate_fill_probability(0.001, 0.8)
|
||
|
|
assert depth_ratio > 0
|
||
|
|
assert fp >= 0
|
||
|
|
|
||
|
|
def test_full_pipeline(self):
|
||
|
|
"""All models work together without errors."""
|
||
|
|
qm = QueuePositionModel()
|
||
|
|
asm = AdverseSelectionModel()
|
||
|
|
lm = LatencyModel()
|
||
|
|
sd = SpreadDynamicsModel()
|
||
|
|
vc = VolatilityClusteringModel()
|
||
|
|
ml = MultiLevelBookModel()
|
||
|
|
mac = MultiAssetCorrelationModel()
|
||
|
|
eqt = ExecutionQualityTracker()
|
||
|
|
ra = RiskAdjustedReturns()
|
||
|
|
|
||
|
|
# Update all models
|
||
|
|
sd.update(2.0)
|
||
|
|
vc.update(20.0)
|
||
|
|
ml.update([1.0, 0.5], [0.8, 0.4])
|
||
|
|
mac.update_returns("BTCUSDT", 0.01)
|
||
|
|
eqt.record_fill(50001.0, 50000.0, 50000.5, True, 10.0, 0.2)
|
||
|
|
ra.add_return(0.01)
|
||
|
|
|
||
|
|
# Query all models
|
||
|
|
fp = qm.estimate_fill_probability(0.001, 1.0, toxicity=0.5)
|
||
|
|
cost = asm.compute_cost(spread_bps=2.0, toxicity=0.5, queue_position=5)
|
||
|
|
lat = lm.simulate_feed_latency()
|
||
|
|
spread = sd.predict(5.0)
|
||
|
|
vol_regime = vc.regime()
|
||
|
|
depth_ratio = ml.compute_depth_ratio()
|
||
|
|
corr = mac.get_btc_correlation("BTCUSDT")
|
||
|
|
exec_report = eqt.report()
|
||
|
|
risk_report = ra.report()
|
||
|
|
|
||
|
|
# All should return valid values
|
||
|
|
assert fp >= 0
|
||
|
|
assert cost.expected_cost_bps >= 0
|
||
|
|
assert lat >= 0
|
||
|
|
assert spread > 0
|
||
|
|
assert 0 <= vol_regime <= 1
|
||
|
|
assert depth_ratio > 0
|
||
|
|
assert isinstance(corr, float)
|
||
|
|
assert exec_report.total_fills == 1
|
||
|
|
assert "sharpe_ratio" in risk_report
|
||
|
|
|
||
|
|
def test_numba_functions_correct(self):
|
||
|
|
"""Verify numba-accelerated functions return same results as Python."""
|
||
|
|
from malkhut.cwm.queue_model import estimate_queue_position, compute_fill_probability
|
||
|
|
# Pure Python equivalents
|
||
|
|
def py_estimate(our_qty, level_qty, rate, time_s):
|
||
|
|
if level_qty <= 0: return 0.0
|
||
|
|
queue_depth = max(0.0, level_qty - our_qty)
|
||
|
|
if rate <= 0: return queue_depth
|
||
|
|
consumed = rate * time_s
|
||
|
|
return max(0.0, queue_depth - consumed)
|
||
|
|
|
||
|
|
def py_fill_prob(qd, oq, rate, horizon, tox):
|
||
|
|
if oq <= 0 or qd < 0: return 0.0
|
||
|
|
if rate <= 0: return 0.0
|
||
|
|
total = qd + oq
|
||
|
|
if total <= 0: return 1.0
|
||
|
|
base = rate / total
|
||
|
|
tox_f = 1.0 + tox * 0.5
|
||
|
|
return min(1.0, max(0.0, 1.0 - math.exp(-base * tox_f * horizon)))
|
||
|
|
|
||
|
|
# Test multiple values
|
||
|
|
for qd in [0.0, 0.5, 1.0, 5.0]:
|
||
|
|
for oq in [0.001, 0.01, 0.1]:
|
||
|
|
for rate in [0.1, 0.5, 1.0]:
|
||
|
|
for tox in [0.0, 0.5, 0.9]:
|
||
|
|
nb_val = compute_fill_probability(qd, oq, rate, 300.0, tox)
|
||
|
|
py_val = py_fill_prob(qd, oq, rate, 300.0, tox)
|
||
|
|
assert abs(nb_val - py_val) < 1e-6
|