Files
QuantDinger/backend_api_python/tests/test_executor_strategy_contract.py
T
TIANHE 942b0f39f7 v5.0.2
Signed-off-by: TIANHE <TIANHE@GMAIL.COM>
2026-07-19 12:33:21 +08:00

364 lines
14 KiB
Python

import pytest
import pandas as pd
from app.services.strategy_runtime.executors import (
build_executor_strategy_payload,
executor_templates,
preview_executor,
)
from app.services.strategy_v2 import compile_strategy_v2
from app.services.strategy_v2 import StrategyV2BacktestRunner, StrategyV2LiveSession
from app.services.strategy_runtime.robot_v2 import migrate_legacy_robot_v2_source
def _robot_payload(executor_type: str, **overrides):
payload = {
"executor_type": executor_type,
"execution_mode": "signal",
"strategy_name": f"V2 {executor_type}",
"symbol": "BTC/USDT",
"market_type": "swap",
"side": "long",
"timeframe": "15m",
"leverage": 3,
"initial_capital": 1000,
"entry_price": 100,
"start_price": 90,
"end_price": 110,
"grid_count": 5,
"total_amount_quote": 500,
"base_order_size": 50,
"safety_order_size": 75,
"price_deviation_pct": 0.01,
"step_multiplier": 1.5,
"volume_multiplier": 1.5,
"max_layers": 4,
"layer_count": 3,
"orders_per_layer": 2,
"take_profit_pct": 0.02,
"trailing_take_profit_enabled": True,
"trailing_activation_pct": 0.01,
"trailing_callback_pct": 0.003,
"hard_stop_pct": 0.1,
}
payload.update(overrides)
return payload
def test_executor_templates_expose_only_supported_robot_types():
catalog = executor_templates()
items = catalog["items"]
assert {item["executor_type"] for item in items} == {
"grid",
"dca",
"martingale",
"layered_martingale",
}
assert catalog["compatibility"]["strategy"]["api_version"] == 2
assert catalog["compatibility"]["backtest"]["supported"] is True
assert catalog["compatibility"]["live"]["credential_required"] is True
assert catalog["compatibility"]["markets"] == ["Crypto"]
for item in items:
defaults = item["defaults"]
assert defaults["dynamic_anchor"] is True
assert "initial_capital" not in defaults
assert "leverage" not in defaults
if item["executor_type"] in {"dca", "martingale", "layered_martingale"}:
assert defaults["trailing_take_profit_enabled"] is True
assert 0 < defaults["trailing_callback_pct"] < defaults["trailing_activation_pct"]
@pytest.mark.parametrize("executor_type", ["grid", "dca", "martingale", "layered_martingale"])
def test_every_robot_generates_a_compilable_strategy_v2_source(executor_type):
payload = build_executor_strategy_payload(_robot_payload(executor_type), user_id=7)
program = compile_strategy_v2(payload["code"])
assert payload["strategy_type"] == "StrategyV2"
assert payload["template_key"] == f"robot_v2_{executor_type}"
assert payload["trading_config"]["api_version"] == 2
assert payload["trading_config"]["strategy_family"] == "robot"
assert program.manifest.api_version == 2
assert program.manifest.strategy_type == "cta"
assert program.manifest.primary_frequency == "15m"
assert program.manifest.leverage_allowed is True
assert program.manifest.max_leverage == 100
assert program.manifest.universe.instruments[0].key == "Crypto:BTC/USDT@swap"
assert payload["compatibility"]["strategy"]["editable_source"] is True
def _runtime_frame():
prices = [100.0, 99.0, 98.0, 101.0, 103.0]
index = pd.date_range("2026-01-01", periods=len(prices), freq="15min")
return pd.DataFrame({
"open": prices,
"high": [price + 2.0 for price in prices],
"low": [price - 2.0 for price in prices],
"close": prices,
"volume": [100000.0] * len(prices),
}, index=index)
@pytest.mark.parametrize("executor_type", ["grid", "dca", "martingale", "layered_martingale"])
def test_every_robot_runs_in_backtest_and_live_v2_engines(executor_type):
payload = build_executor_strategy_payload(
_robot_payload(
executor_type,
initial_position_pct=0.2,
hard_stop_pct=0.2,
),
user_id=7,
)
instrument = "Crypto:BTC/USDT@swap"
frame = _runtime_frame()
result = StrategyV2BacktestRunner(
code=payload["code"],
frames={instrument: frame},
initial_capital=1000,
commission=0,
slippage=0,
leverage_enabled=True,
leverage=3,
).run()
session = StrategyV2LiveSession(
code=payload["code"],
frames={instrument: frame.iloc[:2]},
initial_capital=1000,
)
intents, _, _ = session.process({instrument: frame.iloc[:2]})
assert result["engine"]["version"] == "quantdinger-strategy-api-v2"
assert result["manifest"]["apiVersion"] == 2
assert result["totalExecutions"] >= 1
assert intents
assert all(abs(float(intent.value)) <= 1000 for intent in intents)
@pytest.mark.parametrize("executor_type", ["dca", "martingale", "layered_martingale"])
def test_robot_trailing_take_profit_activates_and_closes_after_pullback(executor_type):
payload = build_executor_strategy_payload(_robot_payload(executor_type), user_id=7)
instrument = "Crypto:BTC/USDT@swap"
frame = _runtime_frame().iloc[:2]
session = StrategyV2LiveSession(
code=payload["code"],
frames={instrument: frame},
initial_capital=1000,
)
intents, _, _ = session.process({instrument: frame})
assert intents
assert "TAKE_PROFIT = 0.0" in payload["code"]
assert "trailing_stop_pct=TRAILING_CALLBACK" in payload["code"]
assert intents[0].protection is not None
assert intents[0].protection.take_profit_pct == 0
assert intents[0].protection.trailing_activation_pct == pytest.approx(0.01)
assert intents[0].protection.trailing_stop_pct == pytest.approx(0.003)
session.synchronize_positions({
instrument: {"side": "long", "amount": 1, "avg_cost": 100, "last_price": 100}
})
assert session.evaluate_protections(
{instrument: 102},
timestamp="2026-01-01 01:00:00",
) == []
restored = StrategyV2LiveSession(
code=payload["code"],
frames={instrument: frame},
initial_capital=1000,
)
restored.restore_protection_snapshot(session.protection_snapshot())
restored.synchronize_positions({
instrument: {"side": "long", "amount": 1, "avg_cost": 100, "last_price": 102}
})
exits = restored.evaluate_protections(
{instrument: 101.5},
timestamp="2026-01-01 01:00:01",
)
assert len(exits) == 1
assert exits[0].kind == "target_quantity"
assert exits[0].value == 0
assert exits[0].reason == "trailing_stop"
@pytest.mark.parametrize("executor_type", ["dca", "martingale", "layered_martingale"])
def test_robot_can_disable_trailing_take_profit_and_keep_fixed_take_profit(executor_type):
payload = build_executor_strategy_payload(
_robot_payload(executor_type, trailing_take_profit_enabled=False),
user_id=7,
)
instrument = "Crypto:BTC/USDT@swap"
frame = _runtime_frame().iloc[:2]
session = StrategyV2LiveSession(
code=payload["code"],
frames={instrument: frame},
initial_capital=1000,
)
intents, _, _ = session.process({instrument: frame})
assert "TAKE_PROFIT = 0.02" in payload["code"]
assert intents[0].protection is not None
assert intents[0].protection.take_profit_pct == pytest.approx(0.02)
assert intents[0].protection.trailing_stop_pct == 0
assert intents[0].protection.trailing_activation_pct == 0
@pytest.mark.parametrize("executor_type", ["dca", "martingale", "layered_martingale"])
def test_robot_preview_rejects_invalid_trailing_take_profit(executor_type):
preview = preview_executor(_robot_payload(
executor_type,
trailing_activation_pct=0.002,
trailing_callback_pct=0.003,
))
assert "invalid_trailing_take_profit" in preview["warnings"]
def test_robot_preview_keeps_each_algorithm_shape():
grid = preview_executor(_robot_payload("grid"))
dca = preview_executor(_robot_payload("dca"))
martingale = preview_executor(_robot_payload("martingale", side="short"))
layered = preview_executor(_robot_payload("layered_martingale"))
assert len(grid["levels"]) == 5
assert len(dca["levels"]) == 4
assert {level["side"] for level in martingale["levels"]} == {"short"}
assert len(layered["levels"]) == 6
def test_default_catalog_robot_can_anchor_levels_to_first_market_price():
payload = build_executor_strategy_payload(
_robot_payload("grid", dynamic_anchor=True, initial_position_pct=0.2),
user_id=7,
)
assert payload["trading_config"]["executor_config"]["dynamic_anchor"] is True
assert "DYNAMIC_ANCHOR = True" in payload["code"]
assert "context.portfolio.starting_cash" in payload["code"]
assert "AMOUNT_WEIGHTS" in payload["code"]
assert "reason=\"grid_initial\"" in payload["code"]
def test_default_grid_uses_weights_and_a_minimum_notional_friendly_initial_share():
defaults = next(
item["defaults"] for item in executor_templates()["items"]
if item["executor_type"] == "grid"
)
preview = preview_executor({
"executor_type": "grid",
"symbol": "BTC/USDT",
**defaults,
})
assert defaults["total_amount_quote"] == defaults["grid_count"]
assert defaults["initial_position_pct"] == pytest.approx(0.6)
assert len(preview["levels"]) == 4
assert all(level["price"] < 1.0 for level in preview["levels"])
assert all(level["amount_quote"] == pytest.approx(2.0) for level in preview["levels"])
assert preview["summary"]["total_amount_quote"] == pytest.approx(defaults["grid_count"])
payload = build_executor_strategy_payload({
"executor_type": "grid",
"execution_mode": "signal",
"symbol": "BTC/USDT",
**defaults,
}, user_id=7)
assert "PRICE_LEVELS = [0.99714286, 0.99142857, 0.98571429, 0.98]" in payload["code"]
assert "if amount != 0:" in payload["code"]
assert "restored_value = max(0.0, g.target_value - initial_value)" in payload["code"]
def test_legacy_robot_absolute_allocations_migrate_to_run_capital_weights():
legacy = """AMOUNTS = [100.0, 300.0]
INITIAL_POSITION_PCT = 0.2
initial_value = sum(AMOUNTS) * INITIAL_POSITION_PCT
g.target_value += float(AMOUNTS[g.next_level] or 0.0)
"""
migrated = migrate_legacy_robot_v2_source(legacy, "grid")
assert "AMOUNT_WEIGHTS = [0.25, 0.75]" in migrated
assert "LEVEL_CAPITAL_FRACTION = 0.8" in migrated
assert "context.portfolio.starting_cash" in migrated
assert "AMOUNTS" not in migrated
def test_live_robot_requires_a_saved_exchange_credential():
with pytest.raises(ValueError, match="LIVE_EXECUTOR_CREDENTIAL_REQUIRED"):
build_executor_strategy_payload(_robot_payload("grid", execution_mode="live"), user_id=7)
payload = build_executor_strategy_payload(
_robot_payload(
"grid",
execution_mode="live",
exchange_config={"credential_id": 42, "exchange_id": "okx"},
),
user_id=7,
)
assert payload["exchange_config"]["credential_id"] == 42
def test_spot_robot_is_forced_to_long_and_cannot_enable_leverage():
payload = build_executor_strategy_payload(
_robot_payload("dca", market_type="spot", side="short", leverage=20),
user_id=7,
)
program = compile_strategy_v2(payload["code"])
assert payload["trade_direction"] == "long"
assert payload["leverage_enabled"] is False
assert program.manifest.leverage_allowed is False
assert program.manifest.direction_mode == "long_only"
assert program.manifest.universe.instruments[0].key == "Crypto:BTC/USDT@spot"
assert "DIRECTION = 1.0" in payload["code"]
def test_neutral_grid_generates_dual_leg_v2_and_resting_live_config():
payload = build_executor_strategy_payload(
_robot_payload("grid", side="neutral", dynamic_anchor=False),
user_id=7,
)
assert payload["trade_direction"] == "neutral"
assert payload["compatibility"]["sides"] == ["long", "short", "neutral"]
assert payload["trading_config"]["bot_type"] == "grid"
assert payload["trading_config"]["bot_params"]["gridDirection"] == "neutral"
assert payload["trading_config"]["bot_params"]["initialPositionPct"] == 0
assert 'position_side="long"' in payload["code"]
assert 'position_side="short"' in payload["code"]
assert compile_strategy_v2(payload["code"]).manifest.direction_mode == "neutral"
instrument = "Crypto:BTC/USDT@swap"
index = pd.date_range("2026-01-01", periods=3, freq="15min")
frame = pd.DataFrame({
"open": [100.0, 100.0, 100.0],
"high": [111.0, 111.0, 111.0],
"low": [89.0, 89.0, 89.0],
"close": [100.0, 100.0, 100.0],
"volume": [100000.0, 100000.0, 100000.0],
}, index=index)
session = StrategyV2LiveSession(
code=payload["code"],
frames={instrument: frame.iloc[:2]},
initial_capital=1000,
)
intents, _, _ = session.process({instrument: frame.iloc[:2]})
assert {intent.position_side for intent in intents} == {"long", "short"}
assert any(intent.position_side == "long" and intent.value > 0 for intent in intents)
assert any(intent.position_side == "short" and intent.value < 0 for intent in intents)
result = StrategyV2BacktestRunner(
code=payload["code"],
frames={instrument: frame},
initial_capital=1000,
commission=0,
slippage=0,
leverage_enabled=True,
leverage=3,
).run()
assert {row["position_side"] for row in result["executions"]} == {"long", "short"}
assert result["audit"]["passed"] is True