""" Tests for MALKHUT Strategy DSL. Verifies: - DSL parsing (text → StrategyTemplate) - DSL decompilation (StrategyTemplate → text) - Action primitives (QUOTE, CROSS, EXIT, etc.) - Market sensors (read values from state) - Decision rules (conditional logic) - Strategy selection (highest priority match) - Builtin strategies - Edge cases (empty strategy, unknown sensor, unknown action) """ import pytest from malkhut.state import ( AccountState, FulfilmentPolicyParams, MarketWorldState, Mode, OrderBookState, PositionState, PriceLevel, Side, TradePathState, VenueRules, ) from malkhut.actions import ActionKind, FulfilmentAction from malkhut.training.dsl import ( ActionType, ActionPrimitive, SensorType, ComparisonOp, SensorCondition, DecisionRule, StrategyTemplate, StrategyDSLParser, StrategyDSLCompiler, DSLParseError, BUILTIN_STRATEGIES, get_builtin_strategy, list_builtin_strategies, _read_sensor, _primitive_to_action, ) 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(bid=50000.0, ask=50001.0, equity=10000.0, **kw): path = kw.get("trade_path") return MarketWorldState( ts_ns=kw.get("ts", 1_000_000_000), mode=Mode.REPLAY_NO_IMPACT, venue=_venue(), book=OrderBookState( ts_ns=1_000_000_000, symbol="BTCUSDT", bids=(PriceLevel(bid, 1.0),), asks=(PriceLevel(ask, 1.0),), ), account=AccountState( ts_ns=1_000_000_000, equity=equity, wallet_balance=equity, available_balance=equity, margin_used=0.0, total_notional=0.0, positions=kw.get("positions", {}), ), trade_path=path, ) # ══════════════════════════════════════════════════════════════════════════════ # 1. SENSOR CONDITIONS # ══════════════════════════════════════════════════════════════════════════════ class TestSensorConditions: def test_spread_bps(self): s = _state(bid=50000.0, ask=50001.0) cond = SensorCondition(SensorType.SPREAD_BPS, ComparisonOp.LT, 5.0) assert cond.evaluate(s) def test_spread_bps_fails(self): s = _state(bid=50000.0, ask=51000.0) # 1000pt spread = ~2000bps cond = SensorCondition(SensorType.SPREAD_BPS, ComparisonOp.LT, 5.0) assert not cond.evaluate(s) # 2000bps > 5bps → False def test_toxicity(self): from malkhut.state import TradePathState tp = TradePathState( symbol="BTCUSDT", side=Side.BUY, entry_ts_ns=0, now_ts_ns=100_000_000, bars_held=5, seconds_held=50.0, pnl_bps=0.0, mae_bps=-10.0, mfe_bps=15.0, distance_from_mfe_bps=15.0, distance_from_entry_bps=0.0, time_to_mfe_s=20.0, time_in_loss_s=50.0, time_in_profit_s=50.0, time_since_last_profit_s=10.0, time_since_deep_mae_s=10.0, loss_to_profit_transitions=1, deep_loss_recoveries=0, failed_recovery_count=0, recovery_velocity_bps_per_s=1.0, adverse_velocity_bps_per_s=-0.5, dolphin_regime_score=0.5, jericho_signal_strength=0.3, volatility_bps=15.0, orderflow_toxicity=0.8, queue_churn_score=0.2, book_imbalance=0.1, cross_venue_lead_score=0.1, ) s = _state(trade_path=tp) cond = SensorCondition(SensorType.TOXICITY, ComparisonOp.GT, 0.5) assert cond.evaluate(s) def test_position_qty(self): pos = PositionState( symbol="BTCUSDT", qty=0.1, avg_entry=50000.0, unrealized_pnl=0.0, realized_pnl=0.0, liquidation_price=None, leverage=0.5, side=Side.BUY, ) s = _state(positions={"BTCUSDT": pos}) cond = SensorCondition(SensorType.POSITION_QTY, ComparisonOp.GT, 0.05) assert cond.evaluate(s) def test_equity(self): s = _state(equity=10000.0) cond = SensorCondition(SensorType.EQUITY, ComparisonOp.GT, 5000.0) assert cond.evaluate(s) # ══════════════════════════════════════════════════════════════════════════════ # 2. SENSOR READING # ══════════════════════════════════════════════════════════════════════════════ class TestSensorReading: def test_read_spread_bps(self): s = _state(bid=50000.0, ask=50001.0) val = _read_sensor(SensorType.SPREAD_BPS, s) assert val > 0 def test_read_equity(self): s = _state(equity=10000.0) val = _read_sensor(SensorType.EQUITY, s) assert val == 10000.0 def test_read_imbalance(self): s = _state() val = _read_sensor(SensorType.IMBALANCE, s) assert isinstance(val, float) # ══════════════════════════════════════════════════════════════════════════════ # 3. DSL PARSER # ══════════════════════════════════════════════════════════════════════════════ class TestDSLParser: def test_parse_simple_strategy(self): parser = StrategyDSLParser() dsl = ''' STRATEGY "test" { PRIORITY 1: IF spread_bps < 5.0 THEN QUOTE(BUY, 0, 0.25) PRIORITY 2: NOOP } ''' template = parser.parse(dsl) assert template.name == "test" assert template.rule_count == 2 def test_parse_multiple_conditions(self): parser = StrategyDSLParser() dsl = ''' STRATEGY "multi" { PRIORITY 1: IF spread_bps < 5.0 AND orderflow_toxicity < 0.3 THEN QUOTE(BUY, 0, 0.25) } ''' template = parser.parse(dsl) assert template.rule_count == 1 assert len(template.rules[0].conditions) == 2 def test_parse_bare_action(self): parser = StrategyDSLParser() dsl = ''' STRATEGY "bare" { PRIORITY 1: NOOP } ''' template = parser.parse(dsl) assert template.rule_count == 1 assert template.rules[0].action.action_type == ActionType.NOOP def test_parse_exit(self): parser = StrategyDSLParser() dsl = ''' STRATEGY "exit_strat" { PRIORITY 1: IF time_in_trade > 300 THEN EXIT } ''' template = parser.parse(dsl) assert template.rules[0].action.action_type == ActionType.EXIT def test_parse_cross(self): parser = StrategyDSLParser() dsl = ''' STRATEGY "cross_strat" { PRIORITY 1: IF spread_bps < 2.0 THEN CROSS(BUY, 0.1) } ''' template = parser.parse(dsl) action = template.rules[0].action assert action.action_type == ActionType.CROSS assert action.side == Side.BUY def test_parse_missing_name(self): parser = StrategyDSLParser() with pytest.raises(DSLParseError): parser.parse("STRATEGY { PRIORITY 1: NOOP }") def test_parse_missing_braces(self): parser = StrategyDSLParser() with pytest.raises(DSLParseError): parser.parse('STRATEGY "test" PRIORITY 1: NOOP') def test_parse_empty_rules(self): parser = StrategyDSLParser() with pytest.raises(DSLParseError): parser.parse('STRATEGY "test" {}') # ══════════════════════════════════════════════════════════════════════════════ # 4. STRATEGY TEMPLATE # ══════════════════════════════════════════════════════════════════════════════ class TestStrategyTemplate: def test_select_action_matches_rule(self): parser = StrategyDSLParser() dsl = ''' STRATEGY "test" { PRIORITY 1: IF spread_bps < 5.0 THEN QUOTE(BUY, 0, 0.25) PRIORITY 2: NOOP } ''' template = parser.parse(dsl) s = _state(bid=50000.0, ask=50001.0) # 1pt spread = 0.2bps action = template.select_action(s) # 0.2bps < 5.0bps → QUOTE matches assert action.kind == ActionKind.PLACE def test_select_action_noop_when_no_match(self): parser = StrategyDSLParser() dsl = ''' STRATEGY "test" { PRIORITY 1: IF spread_bps < 0.01 THEN QUOTE(BUY, 0, 0.25) PRIORITY 2: NOOP } ''' template = parser.parse(dsl) s = _state() action = template.select_action(s) assert action.kind == ActionKind.NOOP def test_rule_count(self): parser = StrategyDSLParser() dsl = ''' STRATEGY "test" { PRIORITY 1: IF spread_bps < 5.0 THEN QUOTE(BUY, 0, 0.25) PRIORITY 2: IF time_in_trade > 300 THEN EXIT PRIORITY 3: NOOP } ''' template = parser.parse(dsl) assert template.rule_count == 3 def test_action_types_used(self): parser = StrategyDSLParser() dsl = ''' STRATEGY "test" { PRIORITY 1: IF spread_bps < 5.0 THEN QUOTE(BUY, 0, 0.25) PRIORITY 2: IF time_in_trade > 300 THEN EXIT PRIORITY 3: NOOP } ''' template = parser.parse(dsl) types = template.action_types_used assert ActionType.QUOTE in types assert ActionType.EXIT in types assert ActionType.NOOP in types def test_sensors_used(self): parser = StrategyDSLParser() dsl = ''' STRATEGY "test" { PRIORITY 1: IF spread_bps < 5.0 AND orderflow_toxicity > 0.5 THEN QUOTE(BUY, 0, 0.25) } ''' template = parser.parse(dsl) sensors = template.sensors_used assert SensorType.SPREAD_BPS in sensors assert SensorType.TOXICITY in sensors def test_evaluate_conditions(self): parser = StrategyDSLParser() dsl = ''' STRATEGY "test" { PRIORITY 1: IF spread_bps < 5.0 THEN QUOTE(BUY, 0, 0.25) PRIORITY 2: NOOP } ''' template = parser.parse(dsl) s = _state() results = template.evaluate_conditions(s) assert len(results) == 2 assert results[0][0] == 1 # priority assert isinstance(results[0][1], bool) # matched # ══════════════════════════════════════════════════════════════════════════════ # 5. DSL COMPILER (roundtrip) # ══════════════════════════════════════════════════════════════════════════════ class TestDSLCompiler: def test_compile_and_decompile(self): compiler = StrategyDSLCompiler() dsl = ''' STRATEGY "roundtrip" { PRIORITY 1: IF spread_bps < 5.0 THEN QUOTE(BUY, 0, 0.25) PRIORITY 2: NOOP } ''' template = compiler.compile(dsl) output = compiler.decompile(template) assert "roundtrip" in output assert "QUOTE" in output assert "NOOP" in output # ══════════════════════════════════════════════════════════════════════════════ # 6. BUILTIN STRATEGIES # ══════════════════════════════════════════════════════════════════════════════ class TestBuiltinStrategies: def test_list_builtins(self): names = list_builtin_strategies() assert "passive_maker" in names assert "aggressive_taker" in names assert "toxicity_avoider" in names assert "path_risk_exit" in names assert "regime_adaptive" in names def test_get_builtin(self): text = get_builtin_strategy("passive_maker") assert text is not None assert "STRATEGY" in text def test_parse_all_builtins(self): compiler = StrategyDSLCompiler() for name in list_builtin_strategies(): text = get_builtin_strategy(name) template = compiler.compile(text) assert template.name == name assert template.rule_count > 0 def test_builtin_passive_maker_parsed(self): compiler = StrategyDSLCompiler() text = get_builtin_strategy("passive_maker") template = compiler.compile(text) assert template.rule_count >= 4 assert SensorType.SPREAD_BPS in template.sensors_used assert SensorType.TOXICITY in template.sensors_used def test_builtin_aggressive_taker_parsed(self): compiler = StrategyDSLCompiler() text = get_builtin_strategy("aggressive_taker") template = compiler.compile(text) assert ActionType.CROSS in template.action_types_used def test_builtin_path_risk_exit_parsed(self): compiler = StrategyDSLCompiler() text = get_builtin_strategy("path_risk_exit") template = compiler.compile(text) assert SensorType.MAE_BPS in template.sensors_used assert ActionType.EXIT in template.action_types_used