import json import pytest from multi_swarm.protocol.grammar import ( ACTION_VALUES, ALL_OPS, COMPARATOR_OPS, CROSSOVER_OPS, KIND_VALUES, LOGICAL_OPS, ) from multi_swarm.protocol.parser import ( FeatureNode, IndicatorNode, LiteralNode, OpNode, ParseError, parse_strategy, ) def test_grammar_constant_sets() -> None: assert LOGICAL_OPS == {"and", "or", "not"} assert COMPARATOR_OPS == {"gt", "lt", "eq"} assert CROSSOVER_OPS == {"crossover", "crossunder"} assert KIND_VALUES == {"indicator", "feature", "literal"} assert ACTION_VALUES == {"entry-long", "entry-short", "exit", "flat"} assert ALL_OPS == LOGICAL_OPS | COMPARATOR_OPS | CROSSOVER_OPS def test_parse_simple_strategy() -> None: src = json.dumps( { "rules": [ { "condition": { "op": "gt", "args": [ {"kind": "indicator", "name": "rsi", "params": [14]}, {"kind": "literal", "value": 70.0}, ], }, "action": "entry-short", } ] } ) ast = parse_strategy(src) assert len(ast.rules) == 1 rule = ast.rules[0] assert rule.action == "entry-short" assert isinstance(rule.condition, OpNode) assert rule.condition.op == "gt" assert isinstance(rule.condition.args[0], IndicatorNode) assert rule.condition.args[0].name == "rsi" assert rule.condition.args[0].params == [14.0] assert isinstance(rule.condition.args[1], LiteralNode) assert rule.condition.args[1].value == 70.0 def test_parse_multiple_rules() -> None: src = json.dumps( { "rules": [ { "condition": { "op": "gt", "args": [ {"kind": "indicator", "name": "rsi", "params": [14]}, {"kind": "literal", "value": 70.0}, ], }, "action": "entry-short", }, { "condition": { "op": "lt", "args": [ {"kind": "indicator", "name": "rsi", "params": [14]}, {"kind": "literal", "value": 30.0}, ], }, "action": "entry-long", }, ] } ) ast = parse_strategy(src) assert len(ast.rules) == 2 def test_parse_feature_leaf() -> None: src = json.dumps( { "rules": [ { "condition": { "op": "crossover", "args": [ {"kind": "feature", "name": "close"}, {"kind": "indicator", "name": "sma", "params": [50]}, ], }, "action": "entry-long", } ] } ) ast = parse_strategy(src) cond = ast.rules[0].condition assert isinstance(cond, OpNode) and cond.op == "crossover" assert isinstance(cond.args[0], FeatureNode) assert cond.args[0].name == "close" def test_parse_unknown_op_raises() -> None: src = json.dumps( { "rules": [ { "condition": {"op": "frobnicate", "args": [1, 2]}, "action": "entry-long", } ] } ) with pytest.raises(ParseError, match="Unknown op"): parse_strategy(src) def test_parse_invalid_action_raises() -> None: src = json.dumps( { "rules": [ { "condition": {"kind": "literal", "value": 1.0}, "action": "buy-now", } ] } ) with pytest.raises(ParseError, match="action"): parse_strategy(src) def test_parse_malformed_json_raises() -> None: with pytest.raises(ParseError, match="invalid JSON"): parse_strategy("{this is not json") def test_parse_top_level_array_raises() -> None: with pytest.raises(ParseError, match="JSON object"): parse_strategy("[1, 2, 3]") def test_parse_missing_rules_key_raises() -> None: with pytest.raises(ParseError, match="rules"): parse_strategy(json.dumps({"foo": "bar"})) def test_parse_empty_rules_raises() -> None: with pytest.raises(ParseError, match="at least one"): parse_strategy(json.dumps({"rules": []})) def test_parse_node_with_both_op_and_kind_raises() -> None: src = json.dumps( { "rules": [ { "condition": {"op": "gt", "kind": "indicator", "args": []}, "action": "flat", } ] } ) with pytest.raises(ParseError, match="mutually exclusive"): parse_strategy(src) def test_parse_indicator_with_nested_node_raises() -> None: src = json.dumps( { "rules": [ { "condition": { "kind": "indicator", "name": "sma", "params": [{"kind": "literal", "value": 14}], }, "action": "flat", } ] } ) with pytest.raises(ParseError, match="params"): parse_strategy(src)