mirror of
https://github.com/shy3130/tick-stock-panel.git
synced 2026-11-02 10:26:13 +00:00
- 策略: 引擎只加载 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 通过
135 lines
4.2 KiB
Python
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")
|