395 lines
14 KiB
Python
395 lines
14 KiB
Python
|
|
"""
|
||
|
|
ASEx integration tests for MALKHUT.
|
||
|
|
|
||
|
|
Tests the validate-before-mutate kernel:
|
||
|
|
- GuardedFulfilmentState: book/account/intent/policy mutations
|
||
|
|
- GuardedRiskState: kill switch + risk decisions
|
||
|
|
- FulfilmentWorker: serialised engine mutations
|
||
|
|
- RiskWorker: serialised risk mutations
|
||
|
|
- FulfilmentWatch: zero-overhead ring buffer
|
||
|
|
- ShardedWorker: per-symbol partitioning
|
||
|
|
- Thread safety under concurrent access
|
||
|
|
"""
|
||
|
|
import threading
|
||
|
|
import time
|
||
|
|
import pytest
|
||
|
|
from concurrent.futures import Future
|
||
|
|
|
||
|
|
from malkhut.state import (
|
||
|
|
AccountState, ExecutionIntent, FulfilmentPolicyParams, IntentKind,
|
||
|
|
MarketWorldState, Mode, OrderBookState, PriceLevel, Side, VenueRules,
|
||
|
|
)
|
||
|
|
from malkhut.actions import ActionKind, FulfilmentAction, PlannedPolicy, RiskDecision
|
||
|
|
from malkhut.execution.asex_integration import (
|
||
|
|
GuardedFulfilmentState,
|
||
|
|
GuardedRiskState,
|
||
|
|
FulfilmentWorker,
|
||
|
|
RiskWorker,
|
||
|
|
FulfilmentWatch,
|
||
|
|
create_sharded_fulfilment,
|
||
|
|
BookUpdate,
|
||
|
|
AccountUpdate,
|
||
|
|
IntentUpdate,
|
||
|
|
PolicyReload,
|
||
|
|
RiskCheck,
|
||
|
|
)
|
||
|
|
from asex.guarded import ValidationError
|
||
|
|
|
||
|
|
|
||
|
|
def _venue():
|
||
|
|
return VenueRules(
|
||
|
|
exchange="bingx", symbol="BTCUSDT", tick_size=0.1, lot_size=0.001,
|
||
|
|
min_qty=0.001, min_notional=5.0, maker_fee_bps=-0.2, taker_fee_bps=0.5,
|
||
|
|
post_only_supported=True, reduce_only_supported=True,
|
||
|
|
max_orders_per_second=100, max_cancels_per_minute=120,
|
||
|
|
)
|
||
|
|
|
||
|
|
|
||
|
|
def _state():
|
||
|
|
return MarketWorldState(
|
||
|
|
ts_ns=1_000_000_000, mode=Mode.REPLAY_NO_IMPACT, venue=_venue(),
|
||
|
|
book=OrderBookState(
|
||
|
|
ts_ns=1_000_000_000, symbol="BTCUSDT",
|
||
|
|
bids=(PriceLevel(50000.0, 1.0),), asks=(PriceLevel(50001.0, 1.0),),
|
||
|
|
),
|
||
|
|
account=AccountState(
|
||
|
|
ts_ns=1_000_000_000, equity=10000.0, wallet_balance=10000.0,
|
||
|
|
available_balance=10000.0, margin_used=0.0, total_notional=0.0,
|
||
|
|
),
|
||
|
|
)
|
||
|
|
|
||
|
|
|
||
|
|
def _params():
|
||
|
|
return FulfilmentPolicyParams(
|
||
|
|
version="asex_test", ucb_c=1.414, max_sims=32, max_depth=2,
|
||
|
|
rollout_depth=1, root_temperature=0.5, min_root_entropy=0.25,
|
||
|
|
quote_offsets_ticks=(0, 1), quote_size_fractions=(0.25,),
|
||
|
|
passive_ttl_ms=200, aggressive_ttl_ms=50,
|
||
|
|
maker_edge_min_bps=0.5, cross_spread_edge_min_bps=5.0,
|
||
|
|
adverse_toxicity_cancel_threshold=0.5, queue_churn_cancel_threshold=0.5,
|
||
|
|
mae_tail_cut_bps=50.0, mfe_giveback_cut_fraction=0.5,
|
||
|
|
max_time_in_loss_s=300.0, failed_recovery_cut_count=3,
|
||
|
|
recovery_velocity_min_bps_per_s=0.0,
|
||
|
|
max_symbol_notional_fraction=0.20, max_single_order_notional_fraction=0.05,
|
||
|
|
reduce_when_global_up_fraction=0.30, session_profit_lock_fraction=0.02,
|
||
|
|
w_expected_pnl=1.0, w_fill_probability=0.5, w_adverse_selection=2.0,
|
||
|
|
w_queue_priority=0.5, w_inventory_risk=1.5, w_tail_loss=5.0,
|
||
|
|
w_fee_quality=0.5, w_time_decay=0.3, w_policy_entropy=0.5,
|
||
|
|
robust_tail_weight=2.0, toxic_counterparty_weight=3.0,
|
||
|
|
low_liquidity_weight=2.0, latency_stress_weight=1.0,
|
||
|
|
)
|
||
|
|
|
||
|
|
|
||
|
|
# ==============================================================================
|
||
|
|
# GuardedFulfilmentState
|
||
|
|
# ==============================================================================
|
||
|
|
|
||
|
|
class TestGuardedFulfilmentState:
|
||
|
|
def test_book_update_valid(self):
|
||
|
|
gs = GuardedFulfilmentState(_state())
|
||
|
|
result = gs.mutate(BookUpdate(
|
||
|
|
ts_ns=2_000_000_000, symbol="BTCUSDT",
|
||
|
|
bids=((50100.0, 2.0),), asks=((50101.0, 1.5),),
|
||
|
|
))
|
||
|
|
assert result is not None
|
||
|
|
assert gs.state.book.best_bid == 50100.0
|
||
|
|
|
||
|
|
def test_book_update_invalid_zero_ts(self):
|
||
|
|
gs = GuardedFulfilmentState(_state())
|
||
|
|
with pytest.raises(ValidationError):
|
||
|
|
gs.mutate(BookUpdate(ts_ns=0, symbol="BTCUSDT", bids=(), asks=()))
|
||
|
|
|
||
|
|
def test_book_update_invalid_empty_bids(self):
|
||
|
|
gs = GuardedFulfilmentState(_state())
|
||
|
|
with pytest.raises(ValidationError):
|
||
|
|
gs.mutate(BookUpdate(ts_ns=1, symbol="BTCUSDT", bids=(), asks=((50001.0, 1.0),)))
|
||
|
|
|
||
|
|
def test_account_update_valid(self):
|
||
|
|
gs = GuardedFulfilmentState(_state())
|
||
|
|
result = gs.mutate(AccountUpdate(
|
||
|
|
ts_ns=2_000_000_000, equity=11000.0, wallet_balance=11000.0,
|
||
|
|
available_balance=11000.0, margin_used=0.0, total_notional=0.0,
|
||
|
|
positions={},
|
||
|
|
))
|
||
|
|
assert result is not None
|
||
|
|
assert gs.state.account.equity == 11000.0
|
||
|
|
|
||
|
|
def test_account_update_invalid_negative_equity(self):
|
||
|
|
gs = GuardedFulfilmentState(_state())
|
||
|
|
with pytest.raises(ValidationError):
|
||
|
|
gs.mutate(AccountUpdate(
|
||
|
|
ts_ns=1, equity=-100.0, wallet_balance=0.0,
|
||
|
|
available_balance=0.0, margin_used=0.0, total_notional=0.0,
|
||
|
|
positions={},
|
||
|
|
))
|
||
|
|
|
||
|
|
def test_intent_update_valid(self):
|
||
|
|
gs = GuardedFulfilmentState(_state())
|
||
|
|
intent = ExecutionIntent(
|
||
|
|
intent_id="t1", ts_ns=1_000_000_000, symbol="BTCUSDT",
|
||
|
|
kind=IntentKind.ENTER_LONG, target_qty=0.01, max_notional=500.0,
|
||
|
|
urgency=0.5, alpha_horizon_s=60.0, alpha_bps=2.0,
|
||
|
|
max_slippage_bps=5.0, prefer_maker=True, reduce_only=False,
|
||
|
|
ttl_s=300.0, reason="test",
|
||
|
|
)
|
||
|
|
result = gs.mutate(IntentUpdate(intent=intent))
|
||
|
|
assert result is not None
|
||
|
|
assert gs.state.intent is not None
|
||
|
|
|
||
|
|
def test_intent_update_none(self):
|
||
|
|
gs = GuardedFulfilmentState(_state())
|
||
|
|
result = gs.mutate(IntentUpdate(intent=None))
|
||
|
|
assert gs.state.intent is None
|
||
|
|
|
||
|
|
def test_policy_reload(self):
|
||
|
|
gs = GuardedFulfilmentState(_state())
|
||
|
|
params = _params()
|
||
|
|
result = gs.mutate(PolicyReload(params=params))
|
||
|
|
assert gs.params is not None
|
||
|
|
assert gs.params.version == "asex_test"
|
||
|
|
|
||
|
|
def test_mutation_count_increments(self):
|
||
|
|
gs = GuardedFulfilmentState(_state())
|
||
|
|
assert gs.mutation_count == 0
|
||
|
|
gs.mutate(IntentUpdate(intent=None))
|
||
|
|
assert gs.mutation_count == 1
|
||
|
|
gs.mutate(IntentUpdate(intent=None))
|
||
|
|
assert gs.mutation_count == 2
|
||
|
|
|
||
|
|
def test_rejected_count_increments(self):
|
||
|
|
gs = GuardedFulfilmentState(_state())
|
||
|
|
assert gs.rejected == 0
|
||
|
|
try:
|
||
|
|
gs.mutate(BookUpdate(ts_ns=0, symbol="BTCUSDT", bids=(), asks=()))
|
||
|
|
except ValidationError:
|
||
|
|
pass
|
||
|
|
assert gs.rejected == 1
|
||
|
|
|
||
|
|
def test_invalid_mutation_type(self):
|
||
|
|
gs = GuardedFulfilmentState(_state())
|
||
|
|
with pytest.raises(ValidationError):
|
||
|
|
gs.mutate("not_a_valid_mutation")
|
||
|
|
|
||
|
|
def test_sequential_mutations_preserve_state(self):
|
||
|
|
gs = GuardedFulfilmentState(_state())
|
||
|
|
gs.mutate(BookUpdate(ts_ns=2, symbol="BTCUSDT",
|
||
|
|
bids=((50100.0, 1.0),), asks=((50101.0, 1.0),)))
|
||
|
|
gs.mutate(AccountUpdate(ts_ns=3, equity=11000.0, wallet_balance=11000.0,
|
||
|
|
available_balance=11000.0, margin_used=0.0,
|
||
|
|
total_notional=0.0, positions={}))
|
||
|
|
assert gs.state.book.best_bid == 50100.0
|
||
|
|
assert gs.state.account.equity == 11000.0
|
||
|
|
|
||
|
|
|
||
|
|
# ==============================================================================
|
||
|
|
# GuardedRiskState
|
||
|
|
# ==============================================================================
|
||
|
|
|
||
|
|
class TestGuardedRiskState:
|
||
|
|
def test_kill_switch_activation(self):
|
||
|
|
gs = GuardedRiskState()
|
||
|
|
assert not gs.kill_switch
|
||
|
|
result = gs.mutate("KILL_SWITCH_ON")
|
||
|
|
assert gs.kill_switch
|
||
|
|
assert not result.approved
|
||
|
|
|
||
|
|
def test_kill_switch_deactivation(self):
|
||
|
|
gs = GuardedRiskState()
|
||
|
|
gs.mutate("KILL_SWITCH_ON")
|
||
|
|
gs.mutate("KILL_SWITCH_OFF")
|
||
|
|
assert not gs.kill_switch
|
||
|
|
|
||
|
|
def test_kill_switch_blocks_risk_check(self):
|
||
|
|
gs = GuardedRiskState()
|
||
|
|
gs.mutate("KILL_SWITCH_ON")
|
||
|
|
action = FulfilmentAction(ActionKind.PLACE, Side.BUY, None, 0, 0.1, 200)
|
||
|
|
planned = PlannedPolicy(actions=(action,), probabilities=(1.0,),
|
||
|
|
selected_action=action, diagnostics={})
|
||
|
|
result = gs.mutate(RiskCheck(state=_state(), planned=planned, params=_params()))
|
||
|
|
assert not result.approved
|
||
|
|
assert "kill_switch" in result.reason
|
||
|
|
|
||
|
|
def test_invalid_mutation_rejected(self):
|
||
|
|
gs = GuardedRiskState()
|
||
|
|
with pytest.raises(ValidationError):
|
||
|
|
gs.mutate(42) # not a valid mutation type
|
||
|
|
|
||
|
|
|
||
|
|
# ==============================================================================
|
||
|
|
# FulfilmentWorker
|
||
|
|
# ==============================================================================
|
||
|
|
|
||
|
|
class TestFulfilmentWorker:
|
||
|
|
def test_worker_creates_and_mutates(self):
|
||
|
|
fw = FulfilmentWorker(_state())
|
||
|
|
future = fw.update_intent(None)
|
||
|
|
assert isinstance(future, Future)
|
||
|
|
result = future.result(timeout=5.0)
|
||
|
|
fw.close()
|
||
|
|
|
||
|
|
def test_worker_state_accessible(self):
|
||
|
|
fw = FulfilmentWorker(_state())
|
||
|
|
assert fw.state is not None
|
||
|
|
assert fw.state.book.best_bid == 50000.0
|
||
|
|
fw.close()
|
||
|
|
|
||
|
|
def test_worker_mutation_count(self):
|
||
|
|
fw = FulfilmentWorker(_state())
|
||
|
|
fw.update_intent(None)
|
||
|
|
fw.update_intent(None)
|
||
|
|
time.sleep(0.05) # let worker process
|
||
|
|
assert fw.mutation_count >= 2
|
||
|
|
fw.close()
|
||
|
|
|
||
|
|
def test_worker_policy_reload(self):
|
||
|
|
fw = FulfilmentWorker(_state())
|
||
|
|
params = _params()
|
||
|
|
fw.reload_policy(params)
|
||
|
|
time.sleep(0.05)
|
||
|
|
assert fw.params is not None
|
||
|
|
fw.close()
|
||
|
|
|
||
|
|
def test_worker_thread_safety(self):
|
||
|
|
fw = FulfilmentWorker(_state())
|
||
|
|
errors = []
|
||
|
|
|
||
|
|
def mutate_loop(idx):
|
||
|
|
try:
|
||
|
|
for i in range(10):
|
||
|
|
fw.update_intent(None)
|
||
|
|
except Exception as e:
|
||
|
|
errors.append((idx, e))
|
||
|
|
|
||
|
|
threads = [threading.Thread(target=mutate_loop, args=(i,)) for i in range(4)]
|
||
|
|
for t in threads:
|
||
|
|
t.start()
|
||
|
|
for t in threads:
|
||
|
|
t.join(timeout=5)
|
||
|
|
time.sleep(0.1)
|
||
|
|
assert len(errors) == 0
|
||
|
|
assert fw.mutation_count >= 40
|
||
|
|
fw.close()
|
||
|
|
|
||
|
|
|
||
|
|
# ==============================================================================
|
||
|
|
# RiskWorker
|
||
|
|
# ==============================================================================
|
||
|
|
|
||
|
|
class TestRiskWorker:
|
||
|
|
def test_kill_switch_activation(self):
|
||
|
|
rw = RiskWorker()
|
||
|
|
assert not rw.kill_switch
|
||
|
|
rw.activate_kill_switch()
|
||
|
|
time.sleep(0.05)
|
||
|
|
assert rw.kill_switch
|
||
|
|
rw.close()
|
||
|
|
|
||
|
|
def test_kill_switch_deactivation(self):
|
||
|
|
rw = RiskWorker()
|
||
|
|
rw.activate_kill_switch()
|
||
|
|
time.sleep(0.05)
|
||
|
|
rw.deactivate_kill_switch()
|
||
|
|
time.sleep(0.05)
|
||
|
|
assert not rw.kill_switch
|
||
|
|
rw.close()
|
||
|
|
|
||
|
|
def test_worker_thread_safety(self):
|
||
|
|
rw = RiskWorker()
|
||
|
|
errors = []
|
||
|
|
|
||
|
|
def toggle_loop():
|
||
|
|
try:
|
||
|
|
for _ in range(5):
|
||
|
|
rw.activate_kill_switch()
|
||
|
|
rw.deactivate_kill_switch()
|
||
|
|
except Exception as e:
|
||
|
|
errors.append(e)
|
||
|
|
|
||
|
|
threads = [threading.Thread(target=toggle_loop) for _ in range(3)]
|
||
|
|
for t in threads:
|
||
|
|
t.start()
|
||
|
|
for t in threads:
|
||
|
|
t.join(timeout=5)
|
||
|
|
time.sleep(0.1)
|
||
|
|
assert len(errors) == 0
|
||
|
|
rw.close()
|
||
|
|
|
||
|
|
|
||
|
|
# ==============================================================================
|
||
|
|
# FulfilmentWatch
|
||
|
|
# ==============================================================================
|
||
|
|
|
||
|
|
class TestFulfilmentWatch:
|
||
|
|
def test_watch_create(self):
|
||
|
|
fw = FulfilmentWatch(_state(), capacity=64)
|
||
|
|
assert fw.state is not None
|
||
|
|
assert fw.pending == 0
|
||
|
|
fw.close()
|
||
|
|
|
||
|
|
def test_watch_mutate_and_poll(self):
|
||
|
|
fw = FulfilmentWatch(_state(), capacity=64)
|
||
|
|
fw.mutate(IntentUpdate(intent=None))
|
||
|
|
count = fw.poll()
|
||
|
|
assert count >= 1
|
||
|
|
fw.close()
|
||
|
|
|
||
|
|
def test_watch_ring_buffer_full(self):
|
||
|
|
fw = FulfilmentWatch(_state(), capacity=4)
|
||
|
|
for _ in range(4):
|
||
|
|
fw.mutate(IntentUpdate(intent=None))
|
||
|
|
with pytest.raises(Exception): # queue.Full
|
||
|
|
fw.mutate(IntentUpdate(intent=None))
|
||
|
|
fw.close()
|
||
|
|
|
||
|
|
|
||
|
|
# ==============================================================================
|
||
|
|
# ShardedWorker
|
||
|
|
# ==============================================================================
|
||
|
|
|
||
|
|
class TestShardedFulfilment:
|
||
|
|
def test_sharded_create(self):
|
||
|
|
sw = create_sharded_fulfilment(n_partitions=4)
|
||
|
|
assert sw.n_partitions == 4
|
||
|
|
sw.close()
|
||
|
|
|
||
|
|
def test_sharded_different_symbols_different_partitions(self):
|
||
|
|
sw = create_sharded_fulfilment(n_partitions=4)
|
||
|
|
f1 = sw.mutate("BTCUSDT", IntentUpdate(intent=None))
|
||
|
|
f2 = sw.mutate("ETHUSDT", IntentUpdate(intent=None))
|
||
|
|
assert isinstance(f1, Future)
|
||
|
|
assert isinstance(f2, Future)
|
||
|
|
sw.close()
|
||
|
|
|
||
|
|
def test_sharded_same_symbol_same_partition(self):
|
||
|
|
sw = create_sharded_fulfilment(n_partitions=4)
|
||
|
|
p1 = sw._shard("BTCUSDT")
|
||
|
|
p2 = sw._shard("BTCUSDT")
|
||
|
|
assert p1 == p2
|
||
|
|
sw.close()
|
||
|
|
|
||
|
|
|
||
|
|
# ==============================================================================
|
||
|
|
# Engine with ASEx
|
||
|
|
# ==============================================================================
|
||
|
|
|
||
|
|
class TestEngineASEx:
|
||
|
|
def test_engine_has_workers(self):
|
||
|
|
from malkhut.engine import FulfilmentEngine
|
||
|
|
engine = FulfilmentEngine(params_provider=_params)
|
||
|
|
assert engine.fulfilment_worker is not None
|
||
|
|
assert engine.risk_worker is not None
|
||
|
|
engine.close()
|
||
|
|
|
||
|
|
def test_engine_close_cleans_up(self):
|
||
|
|
from malkhut.engine import FulfilmentEngine
|
||
|
|
engine = FulfilmentEngine(params_provider=_params)
|
||
|
|
engine.close()
|
||
|
|
assert not engine._active
|
||
|
|
|
||
|
|
def test_engine_on_state_with_asex(self):
|
||
|
|
from malkhut.engine import FulfilmentEngine
|
||
|
|
engine = FulfilmentEngine(params_provider=_params)
|
||
|
|
engine.on_state(_state())
|
||
|
|
assert engine.fulfilment_worker.mutation_count >= 0
|
||
|
|
engine.close()
|