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

358 lines
14 KiB
Python
Raw Normal View History

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