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