Files
sentiment-engine/MALKHUT/malkhut/tests/test_asex_integration.py

395 lines
14 KiB
Python
Raw Normal View History

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