feat(composite): 新增叠加策略(composite)支持选股与回测

引入第四种执行后端 composite: 声明式引用多个子策略, 复用现有注册表/缓存/撮合全链路。

核心设计:
- 退出采用来源投影(每个子策略 exit 仅在自己持仓窗口生效, 不串平其他子策略仓位)
- 评分用标准化排名加权(跨子策略可比, 不依赖 per-strategy 的 min-max 量纲)
- 来源归因(entry_signal_code 标记来源子策略)

后端:
- engine.py: CompositeSpec 数据模型, 两阶段引用校验(孤儿移除/嵌套禁止/asset_types 子集/≤8 上限), _run_composite_strategy 选股执行, find_dependents 删除防护, override_loader 注入保证子策略与单独跑同口径
- composite.py(新): 选股 merge_results + 回测 merge_signal_matrices 合并器(退出投影/排名融合/归因)
- backtest/strategy.py: composite 回测分支(特征计划合并/逐子信号/统一风控/basic_filter/归因)
- api/strategy.py: POST /composite/save 端点, _strategy_detail 返回 composite_children(含 name/source), 删除依赖 409 防护
- monitor.py: 实时监控 fail-closed(回退盘后缓存)
- walkforward.py: 对 composite 显式中文报错
- worker.py: _strategy_dirs 补 composite 目录(修复回测子进程找不到 composite 的 bug)

前端:
- CompositeStrategyDialog(新): 创建/编辑弹窗(自动生成 composite_ ID, 权重归一, 类型标签, 增删子策略)
- StrategySettingsDialog: composite 设置面板(子策略增删改/归一/类型标签)
- StrategyBacktest: 回测页 composite 适配(分组/参数区/结果区展示子策略构成)
- api.ts: 类型扩展(source/backend 加 composite, composite_children 含 name/source)

测试: 36 个 composite 专项测试(加载/选股/回测退出投影/排名/归因/override 透传/API/删除防护), 端到端验证真实内置策略回测通过
This commit is contained in:
shy3130
2026-08-02 10:56:03 +08:00
parent 8ead30037a
commit 4696fef959
21 changed files with 2621 additions and 40 deletions
+186 -6
View File
@@ -118,7 +118,22 @@ def _safe(result_dict: dict) -> dict:
return result_dict
def _strategy_detail(s: StrategyDef, overrides: dict | None = None) -> dict:
def _child_meta(engine: StrategyEngine | None, child_id: str) -> dict:
"""查询子策略的可读名称与来源; engine 缺失或子策略不存在时回退为 id/unknown。"""
if engine is None:
return {"name": child_id, "source": "unknown"}
try:
child = engine.get(child_id)
except ValueError:
return {"name": child_id, "source": "unknown"}
return {"name": str(child.meta.get("name") or child_id), "source": child.source}
def _strategy_detail(
s: StrategyDef,
overrides: dict | None = None,
engine: StrategyEngine | None = None,
) -> dict:
"""策略详情(含用户覆盖)"""
bf = {**s.basic_filter}
scoring = dict(s.meta.get("scoring", {}))
@@ -165,6 +180,19 @@ def _strategy_detail(s: StrategyDef, overrides: dict | None = None) -> dict:
"descending": s.meta.get("descending", True),
"limit": s.meta.get("limit", 30),
"display_limit": overrides.get("display_limit") if overrides and "display_limit" in overrides else None,
# 叠加策略: 子策略列表与权重(供前端展示)。override.children 可覆盖 META 固化值。
"composite_children": (
[
{
"id": c["strategy_id"],
**_child_meta(engine, c["strategy_id"]),
"weight": c.get("weight", 1.0),
}
for c in (overrides.get("children") if overrides and isinstance(overrides.get("children"), list) else s.meta.get("children", []))
]
if s.execution_backend == "composite"
else None
),
}
@@ -218,6 +246,21 @@ class StrategyCodeSaveRequest(BaseModel):
description: str = ""
class CompositeChildItem(BaseModel):
strategy_id: str
weight: float = 1.0
class StrategyCompositeSaveRequest(BaseModel):
strategy_id: str
name: str = ""
description: str = ""
children: list[CompositeChildItem]
merge_mode: Literal["union", "intersect"] = "union"
min_confirm: int = 0
mode: Literal["create", "update"] = "create"
class MonitorStartRequest(BaseModel):
strategy_id: str
@@ -244,7 +287,7 @@ def list_strategies(
sid = meta["id"]
s = engine.get(sid)
overrides = all_overrides.get(sid)
result.append(_strategy_detail(s, overrides))
result.append(_strategy_detail(s, overrides, engine))
return {"strategies": result, "load_errors": engine.load_errors()}
@@ -256,7 +299,7 @@ def get_strategy(strategy_id: str, request: Request):
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e)) from e
overrides = strategy_config.load_override(_data_dir(request), strategy_id)
return _strategy_detail(s, overrides or None)
return _strategy_detail(s, overrides or None, engine)
# ── 执行选股 ─────────────────────────────────────────────────────────
@@ -534,8 +577,8 @@ def _validate_strategy_id(strategy_id: str) -> str:
def _target_dir(data_dir: Path, source: str) -> Path:
if source not in {"ai", "custom"}:
raise ValueError("target_source 必须是 aicustom")
if source not in {"ai", "custom", "composite"}:
raise ValueError("target_source 必须是 aicustom 或 composite")
return data_dir / "strategies" / source
@@ -796,6 +839,135 @@ def save_strategy_code(req: StrategyCodeSaveRequest, request: Request):
raise HTTPException(status_code=400, detail=str(e)) from e
def _render_composite_code(
sid: str,
name: str,
description: str,
children: list[dict],
merge_mode: str,
min_confirm: int,
) -> str:
"""渲染声明式 composite 策略 .py 文件内容。
composite 不含业务代码, 仅通过 META.children 引用子策略 + EXECUTION_BACKEND 声明。
权重固化在 META; merge_mode/min_confirm 作为 params(可经 override 轻量调整)。
"""
import json as _json
children_json = ",\n ".join(
_json.dumps({"strategy_id": c["strategy_id"], "weight": c["weight"]}, ensure_ascii=False)
for c in children
)
safe_name = name or sid
return f'''"""叠加策略 {sid}(自动生成, 请勿手改业务逻辑)。"""
META = {{
"id": {sid!r},
"name": {safe_name!r},
"description": {description!r},
"asset_types": ["stock"],
"timeframes": ["1d"],
"params": [
{{"id": "merge_mode", "label": "合并模式", "type": "select",
"options": ["union", "intersect"], "default": {merge_mode!r}}},
{{"id": "min_confirm", "label": "交集最少确认数", "type": "int",
"default": {int(min_confirm)!r}, "min": 0}},
],
"scoring": {{}},
"order_by": "score",
"descending": True,
"limit": 100,
"children": [
{children_json}
],
}}
EXECUTION_BACKEND = "composite"
'''
def _save_composite_strategy(req: StrategyCompositeSaveRequest, request: Request) -> dict:
"""保存叠加策略: 渲染声明式 .py → 写盘 → reload → 校验。"""
sid = _validate_strategy_id(req.strategy_id)
if not sid.startswith("composite_"):
raise ValueError("叠加策略 ID 必须以 composite_ 开头")
engine = _get_engine(request)
data_dir = _data_dir(request)
existing: StrategyDef | None = None
try:
existing = engine.get(sid)
except ValueError:
existing = None
if req.mode == "create":
if existing is not None:
raise ValueError(f"策略 {sid} 已存在,请改用修改模式或换一个策略 ID")
else: # update
if existing is None:
raise ValueError(f"策略 {sid} 不存在")
if existing.source == "builtin":
raise ValueError("内置策略不可覆盖")
# 只允许覆盖 composite 策略(防止把普通策略覆盖成 composite)
if existing.execution_backend != "composite":
raise ValueError("目标策略不是叠加策略,无法以叠加模式覆盖")
if not req.children:
raise ValueError("叠加策略至少需要一个子策略")
children = [{"strategy_id": c.strategy_id, "weight": c.weight} for c in req.children]
# 子策略存在性预检(给出清晰错误, 而非等到 reload 后孤儿移除的笼统报错)。
for c in children:
if not engine.has(c["strategy_id"]):
raise ValueError(f"子策略 {c['strategy_id']!r} 不存在")
try:
child_def = engine.get(c["strategy_id"])
if child_def.execution_backend == "composite":
raise ValueError(f"子策略 {c['strategy_id']!r} 也是叠加策略; 首版禁止嵌套叠加")
except ValueError:
raise
code = _render_composite_code(
sid, req.name, req.description, children, req.merge_mode, req.min_confirm
)
out_dir = _target_dir(data_dir, "composite")
out_dir.mkdir(parents=True, exist_ok=True)
path = out_dir / f"{sid}.py"
previous_code = path.read_text(encoding="utf-8") if path.exists() else None
path.write_text(code, encoding="utf-8")
try:
engine.reload()
loaded = engine.get(sid)
if loaded.file_path is None or loaded.file_path.resolve() != path.resolve():
raise ValueError("策略加载到了非预期文件,请检查是否存在重复 strategy_id")
if loaded.source != "composite":
raise ValueError(f"策略来源异常: 期望 composite, 实际 {loaded.source}")
if loaded.execution_backend != "composite":
raise ValueError("策略后端异常: 期望 composite")
except Exception as e:
_restore_strategy_file(path, previous_code)
engine.reload()
raise ValueError(f"叠加策略保存失败: {e}") from e
_invalidate_strategy_runtime(request)
return {
"ok": True,
"strategy_id": sid,
"source": "composite",
"path": str(path),
}
@router.post("/composite/save")
def save_composite_strategy(req: StrategyCompositeSaveRequest, request: Request):
try:
return _save_composite_strategy(req, request)
except Exception as e:
raise HTTPException(status_code=400, detail=str(e)) from e
@router.post("/ai/save")
async def ai_save(req: AISaveRequest, request: Request):
try:
@@ -827,9 +999,17 @@ def delete_strategy(strategy_id: str, request: Request):
if s.source == "builtin":
raise HTTPException(status_code=403, detail="内置策略不可删除")
# 删除被引用的子策略会令叠加策略加载失败; 删除前 fail-closed 阻止。
dependents = engine.find_dependents(strategy_id)
if dependents:
raise HTTPException(
status_code=409,
detail=f"该策略被叠加策略 {dependents} 引用,请先解除引用后再删除",
)
path = s.file_path
data_dir = _data_dir(request)
if path is None or s.source not in {"custom", "ai"}:
if path is None or s.source not in {"custom", "ai", "composite"}:
raise HTTPException(status_code=400, detail="策略源文件路径无效, 无法删除")
try: