mirror of
https://ghfast.top/https://github.com/aeroxw/tick-stock-panel.git
synced 2026-09-12 17:54:15 +08:00
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:
+186
-6
@@ -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 必须是 ai 或 custom")
|
||||
if source not in {"ai", "custom", "composite"}:
|
||||
raise ValueError("target_source 必须是 ai、custom 或 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:
|
||||
|
||||
Reference in New Issue
Block a user