mirror of
https://ghfast.top/https://github.com/aeroxw/tick-stock-panel.git
synced 2026-09-12 17:54:15 +08:00
195 lines
5.8 KiB
Python
195 lines
5.8 KiB
Python
from __future__ import annotations
|
|
|
|
from types import SimpleNamespace
|
|
|
|
import pytest
|
|
|
|
from app.api.strategy import (
|
|
StrategyCodeSaveRequest,
|
|
StrategyCodeValidateRequest,
|
|
_prepare_strategy_code,
|
|
_save_strategy_code,
|
|
)
|
|
from app.strategy.engine import StrategyEngine
|
|
|
|
|
|
def _code(strategy_id: str, name: str = "测试策略") -> str:
|
|
return f'''"""测试策略"""
|
|
import polars as pl
|
|
|
|
META = {{
|
|
"id": "{strategy_id}",
|
|
"name": "{name}",
|
|
"description": "测试描述",
|
|
"tags": ["测试"],
|
|
"params": [],
|
|
"scoring": {{}},
|
|
}}
|
|
|
|
ENTRY_SIGNALS = []
|
|
EXIT_SIGNALS = []
|
|
STOP_LOSS = -0.05
|
|
MAX_HOLD_DAYS = 20
|
|
|
|
RULES = """
|
|
1. 测试规则一
|
|
2. 测试规则二
|
|
3. 测试规则三
|
|
"""
|
|
|
|
def filter(df: pl.DataFrame, params: dict) -> pl.Expr:
|
|
return pl.lit(True)
|
|
'''
|
|
|
|
|
|
def _request(tmp_path):
|
|
ai_dir = tmp_path / "strategies" / "ai"
|
|
custom_dir = tmp_path / "strategies" / "custom"
|
|
engine = StrategyEngine(strategy_dirs=[custom_dir, ai_dir])
|
|
repo = SimpleNamespace(store=SimpleNamespace(data_dir=tmp_path))
|
|
return SimpleNamespace(app=SimpleNamespace(state=SimpleNamespace(repo=repo, strategy_engine=engine)))
|
|
|
|
|
|
def test_prepare_strategy_code_rejects_forbidden_import():
|
|
req = StrategyCodeValidateRequest(
|
|
strategy_id="custom_bad",
|
|
code='''import os\nMETA = {"id": "custom_bad"}\n''',
|
|
)
|
|
|
|
with pytest.raises(ValueError, match="禁止 import os"):
|
|
_prepare_strategy_code(req)
|
|
|
|
|
|
def test_prepare_strategy_code_rejects_unknown_scoring_field():
|
|
req = StrategyCodeValidateRequest(
|
|
strategy_id="custom_bad_score",
|
|
code=_code("custom_bad_score").replace(
|
|
'"scoring": {},',
|
|
'"scoring": {"volume_surge": 1.0},',
|
|
),
|
|
)
|
|
|
|
with pytest.raises(ValueError, match="volume_surge"):
|
|
_prepare_strategy_code(req)
|
|
|
|
|
|
def test_save_strategy_code_creates_ai_strategy_in_ai_dir(tmp_path):
|
|
request = _request(tmp_path)
|
|
req = StrategyCodeSaveRequest(
|
|
strategy_id="ai_saved",
|
|
target_source="ai",
|
|
mode="create",
|
|
code=_code("wrong"),
|
|
name="AI 策略",
|
|
)
|
|
|
|
result = _save_strategy_code(req, request)
|
|
|
|
assert result["ok"] is True
|
|
assert result["source"] == "ai"
|
|
assert (tmp_path / "strategies" / "ai" / "ai_saved.py").exists()
|
|
loaded = request.app.state.strategy_engine.get("ai_saved")
|
|
assert loaded.source == "ai"
|
|
assert loaded.file_path == tmp_path / "strategies" / "ai" / "ai_saved.py"
|
|
|
|
|
|
def test_save_strategy_code_creates_custom_strategy_in_custom_dir(tmp_path):
|
|
request = _request(tmp_path)
|
|
req = StrategyCodeSaveRequest(
|
|
strategy_id="custom_saved",
|
|
target_source="custom",
|
|
mode="create",
|
|
code=_code("wrong"),
|
|
name="自定义策略",
|
|
)
|
|
|
|
result = _save_strategy_code(req, request)
|
|
|
|
assert result["ok"] is True
|
|
assert result["source"] == "custom"
|
|
assert (tmp_path / "strategies" / "custom" / "custom_saved.py").exists()
|
|
loaded = request.app.state.strategy_engine.get("custom_saved")
|
|
assert loaded.source == "custom"
|
|
assert loaded.file_path == tmp_path / "strategies" / "custom" / "custom_saved.py"
|
|
|
|
|
|
def test_save_strategy_code_updates_existing_source_file(tmp_path):
|
|
request = _request(tmp_path)
|
|
create = StrategyCodeSaveRequest(
|
|
strategy_id="custom_update",
|
|
target_source="custom",
|
|
mode="create",
|
|
code=_code("custom_update", "旧名称"),
|
|
)
|
|
_save_strategy_code(create, request)
|
|
|
|
update = StrategyCodeSaveRequest(
|
|
strategy_id="custom_update",
|
|
target_source="ai",
|
|
mode="update",
|
|
code=_code("custom_update", "新名称"),
|
|
)
|
|
result = _save_strategy_code(update, request)
|
|
|
|
assert result["source"] == "custom"
|
|
custom_path = tmp_path / "strategies" / "custom" / "custom_update.py"
|
|
assert custom_path.exists()
|
|
assert not (tmp_path / "strategies" / "ai" / "custom_update.py").exists()
|
|
assert '"name": "新名称"' in custom_path.read_text(encoding="utf-8")
|
|
|
|
|
|
def test_save_strategy_code_rejects_undefined_custom_signal(tmp_path):
|
|
"""REQUIRED_FEATURES 引用未定义的自定义信号 → 拒绝保存并恢复文件。
|
|
|
|
回归: 之前保存不校验, 运行期才抛 polars 缺列错 (500)。
|
|
"""
|
|
request = _request(tmp_path)
|
|
code = _code("custom_missing_sig") + (
|
|
'\nREQUIRED_FEATURES = {"csg_oversold_macd_about_to_golden"}\n'
|
|
)
|
|
req = StrategyCodeSaveRequest(
|
|
strategy_id="custom_missing_sig",
|
|
target_source="custom",
|
|
mode="create",
|
|
code=code,
|
|
name="引用不存在信号的策略",
|
|
)
|
|
|
|
with pytest.raises(ValueError, match="csg_oversold_macd_about_to_golden"):
|
|
_save_strategy_code(req, request)
|
|
|
|
# 校验失败不落盘
|
|
assert not (tmp_path / "strategies" / "custom" / "custom_missing_sig.py").exists()
|
|
|
|
|
|
def test_save_strategy_code_ok_when_custom_signal_defined(tmp_path):
|
|
"""信号已定义时, 引用它的策略可以正常保存。"""
|
|
from app.strategy import custom_signals
|
|
|
|
custom_signals.save_one(tmp_path, {
|
|
"id": "oversold_macd_about_to_golden",
|
|
"name": "超跌接近金叉",
|
|
"kind": "entry",
|
|
"conditions": [
|
|
{"left": "momentum_60d", "op": "<=", "right": "-0.30",
|
|
"leftDays": 0, "rightDays": 0},
|
|
],
|
|
"enabled": True,
|
|
})
|
|
request = _request(tmp_path)
|
|
code = _code("custom_with_sig") + (
|
|
'\nREQUIRED_FEATURES = {"csg_oversold_macd_about_to_golden"}\n'
|
|
)
|
|
req = StrategyCodeSaveRequest(
|
|
strategy_id="custom_with_sig",
|
|
target_source="custom",
|
|
mode="create",
|
|
code=code,
|
|
name="引用已定义信号的策略",
|
|
)
|
|
|
|
result = _save_strategy_code(req, request)
|
|
assert result["ok"] is True
|
|
loaded = request.app.state.strategy_engine.get("custom_with_sig")
|
|
assert "csg_oversold_macd_about_to_golden" in loaded.required_features
|