358 lines
14 KiB
Python
358 lines
14 KiB
Python
|
|
"""
|
||
|
|
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
|