Files
tick-stock-panel/backend/tests/test_strategy_registry.py
T
shy3130 918c7ade7e refactor(core): 策略与信号全面去内置化, 转为用户自定义定义
- 策略: 引擎只加载 data/strategies/{custom,ai,composite} 与研究模板目录,
  原 25 个内置策略一次性迁移为用户自定义文件(ID 不变, 引用零迁移);
  研究模板移至 app/strategy/research, 打包 spec 同步; 新装环境策略库为空
- 信号: 管线不再硬编码任何信号表达式; 涨跌停判定价成为运行时列
  limit_up_price/limit_down_price(生效价∓0.005 容差), 20 个信号转为
  data/user_data/custom_signals 定义(显式 column 沿用 signal_* 原列名);
  依赖解析/注入顺序/当日路径 prev-join 全部定义驱动; 语义统一为历史路径
  口径(顺带修正当日路径 BOLL 比较符与翘板/炸板空值两处既有偏差)
- 前端: 信号库单列表(全部可编辑), SignalPicker/监控选项/信号标签全走
  定义接口; 回测页新增免责声明(模拟性质/数据源准确性不作保证/不构成
  投资建议)
- 测试: 25 个策略夹具与 20 个信号种子入库 tests/fixtures, 受影响测试
  全部自包含化(不依赖运行目录用户数据); 77 万行 MA 死叉新旧表达式对拍
  逐行一致; 回测实跑 45 笔卖点信号出场验证矩阵链路
- 同批包含工作区既有进度: 策略结果 ext 列同日投影修复(含测试)、数据源插件、fuyao/auth/api_gateway/data_integrity 调整、README 信号库
  描述与 strategy.md 导入路径补充

验证: 后端 2592 passed(空数据目录 CI 模拟 2588 passed, 4 例为模拟环境
伪影复验排除); 前端 vitest 125 passed + tsc 0 错 + vite build 通过
2026-10-10 21:10:44 +08:00

135 lines
4.2 KiB
Python

from __future__ import annotations
from datetime import date
import polars as pl
import pytest
from app.strategy.engine import StrategyDataContext, StrategyEngine
def _strategy_code(strategy_id: str, *, body: str = "return pl.lit(True)") -> str:
return f'''import polars as pl
META = {{
"id": "{strategy_id}",
"name": "{strategy_id}",
"asset_types": ["stock"],
"timeframes": ["1d"],
}}
EXECUTION_BACKEND = "polars_expr"
def filter(df, params):
{body}
'''
def test_duplicate_strategy_id_reports_both_paths(tmp_path):
first = tmp_path / "first"
second = tmp_path / "second"
first.mkdir()
second.mkdir()
(first / "a.py").write_text(_strategy_code("duplicate"), encoding="utf-8")
(second / "b.py").write_text(_strategy_code("duplicate"), encoding="utf-8")
engine = StrategyEngine(strategy_dirs=[first, second])
assert not engine.has("duplicate")
errors = engine.load_errors()
assert len(errors) == 2
assert {item["file"] for item in errors} == {str(first / "a.py"), str(second / "b.py")}
assert all("duplicate strategy id" in item["error"] for item in errors)
def test_failed_reload_keeps_previous_registry(tmp_path):
path = tmp_path / "stable.py"
path.write_text(_strategy_code("stable"), encoding="utf-8")
engine = StrategyEngine(strategy_dirs=[tmp_path])
previous = engine.get("stable")
path.write_text("this is not valid python", encoding="utf-8")
with pytest.raises(ValueError, match="strategy reload failed"):
engine.reload()
assert engine.get("stable") is previous
assert engine.load_errors()
def test_context_rejects_unsupported_timeframe(tmp_path):
path = tmp_path / "daily.py"
path.write_text(_strategy_code("daily"), encoding="utf-8")
engine = StrategyEngine(strategy_dirs=[tmp_path])
with pytest.raises(ValueError, match="does not support timeframe"):
engine.run(
"daily",
StrategyDataContext(
asset_type="stock",
timeframe="5m",
as_of=date(2026, 1, 2),
current=pl.DataFrame({"symbol": ["000001.SZ"]}),
),
)
def test_run_all_respects_explicit_empty_strategy_ids(tmp_path):
path = tmp_path / "daily.py"
path.write_text(_strategy_code("daily"), encoding="utf-8")
engine = StrategyEngine(strategy_dirs=[tmp_path])
context = StrategyDataContext(
asset_type="stock",
timeframe="1d",
as_of=date(2026, 1, 2),
current=pl.DataFrame({"symbol": ["000001.SZ"]}),
)
assert engine.run_all(context, strategy_ids=[]) == {}
def test_all_sources_share_one_registry_and_run_path(tmp_path):
strategy_ids = {
"custom": "custom_plugin",
"ai": "ai_plugin",
"composite": "composite_plugin",
}
dirs = []
for source, strategy_id in strategy_ids.items():
directory = tmp_path / "strategies" / source
directory.mkdir(parents=True)
(directory / f"{strategy_id}.py").write_text(
_strategy_code(strategy_id),
encoding="utf-8",
)
dirs.append(directory)
engine = StrategyEngine(strategy_dirs=dirs)
sources = {meta["id"]: meta["source"] for meta in engine.list_strategies()}
assert sources == {strategy_id: source for source, strategy_id in strategy_ids.items()}
context = StrategyDataContext(
asset_type="stock",
timeframe="1d",
as_of=date(2026, 1, 2),
current=pl.DataFrame({"symbol": ["000001.SZ"]}),
)
overrides = {
strategy_id: {"basic_filter": {"enabled": False}}
for strategy_id in strategy_ids.values()
}
results = engine.run_all(context, overrides_map=overrides)
assert set(results) == set(strategy_ids.values())
assert all(result.total == 1 for result in results.values())
def test_legacy_alerts_global_is_ignored(tmp_path):
path = tmp_path / "legacy_alerts.py"
path.write_text(
_strategy_code("legacy_alerts")
+ '\nALERTS = [{"field": "rsi_14", "op": "<", "value": 25}]\n',
encoding="utf-8",
)
engine = StrategyEngine(strategy_dirs=[tmp_path])
assert engine.has("legacy_alerts")
assert not hasattr(engine.get("legacy_alerts"), "alerts")