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:
+227 -2
View File
@@ -598,6 +598,128 @@ class StrategyBacktestService:
json.dumps(config.overrides or {}, sort_keys=True, ensure_ascii=False, default=str),
)
def _resolve_composite_feature_plan(
self,
strategy: StrategyDef,
*,
params: dict,
basic_filter: dict,
overrides: dict,
) -> tuple[ResolvedFeaturePlan, list[tuple[StrategyDef, dict, dict]]]:
"""解析 composite 回测的特征计划: 所有子策略 feature_plan 的并集。
返回 (合并 feature_plan, [(子策略定义, 子params, 子pipeline_config_dict), ...])。
子策略必须全为 matrix_native, 否则 fail-closed(首版硬约束)。
"""
from app.strategy import composite as composite_mod
from app.strategy.engine import _parse_composite_children
assert strategy.composite is not None
# 权重: override.children 优先, 否则 META 声明。
override_children = overrides.get("children")
if isinstance(override_children, list) and override_children:
spec = _parse_composite_children(override_children)
children = spec.children
else:
children = strategy.composite.children
resolver = StrategyDependencyResolver()
plans: list[ResolvedFeaturePlan] = []
resolved_children: list[tuple[StrategyDef, dict, dict]] = []
for child in children:
child_def = self.strategy_engine.get(child.strategy_id)
if child_def.execution_backend != "matrix_native":
raise ValueError(
f"叠加回测暂仅支持矩阵子策略; {child.strategy_id!r} "
f"{child_def.execution_backend}"
)
if child_def.matrix_strategy is None:
raise ValueError(f"子策略 {child.strategy_id!r} 未注册矩阵策略")
# 加载子策略的用户 override(参数/评分等), 保证回测与单独跑子策略同口径。
child_override: dict = {}
loader = getattr(self.strategy_engine, "_override_loader", None)
if loader is not None:
try:
loaded = loader(child.strategy_id)
if isinstance(loaded, dict):
child_override = dict(loaded)
except Exception: # noqa: BLE001
pass
child_params = self.strategy_engine.resolve_params(child_def, overrides=child_override)
child_plan = resolver.resolve(
child_def,
params=child_params,
basic_filter=basic_filter, # 统一 basic_filter(计划 §3.3)
entry_signals=[],
exit_signals=[],
overrides=child_override,
)
plans.append(child_plan)
# pipeline 用 composite 统一的 basic_filter; scoring 用子策略自己的
# (默认 + 用户 override), 因为子策略内部排序影响合并器的排名融合。
child_scoring = dict(child_def.meta.get("scoring", {}) or {})
if isinstance(child_override.get("scoring"), dict):
child_scoring.update(child_override["scoring"])
child_pipeline_cfg = MatrixPipelineConfig(
basic_filter=basic_filter,
scoring=child_scoring,
order_by=child_def.meta.get("order_by"),
descending=bool(child_def.meta.get("descending", True)),
protect_strategy_cache=False,
)
resolved_children.append((child_def, child_params, child_pipeline_cfg))
merged_plan = _merge_resolved_feature_plans(plans)
# composite 模块用于合并时读取权重列表(顺序对齐 resolved_children)。
self._composite_children_weights = [(c.strategy_id, c.weight) for c in children]
_ = composite_mod # 确保模块可导入(回测时由调用方使用)
return merged_plan, resolved_children
def _generate_composite_signal_matrix(
self,
resolved_children: list[tuple[StrategyDef, dict, dict]],
market_data: MarketDataMatrix,
merge_mode: str,
min_confirm: int,
max_hold: int,
timing_ms: dict[str, float],
):
"""逐子策略计算 SignalMatrix, 再合并为单个 SignalMatrix(回测合并)。
合并语义见 app.strategy.composite.merge_signal_matrices:
- entry: union/intersect
- exit: 来源投影(每个子的 exit 仅在自己持仓窗口生效, 不串平)
- score: 标准化排名加权
"""
from app.strategy import composite as composite_mod
t_signals = time.perf_counter()
sigs = []
for child_def, child_params, child_pipeline_cfg in resolved_children:
try:
child_sig = MatrixStrategyPipeline().run(
child_def.matrix_strategy,
market_data,
child_params,
child_pipeline_cfg,
)
except (TypeError, ValueError) as e:
raise ValueError(f"子策略 {child_def.meta.get('id')} 信号计算失败: {e}") from e
sigs.append(child_sig)
timing_ms["strategy_signals"] = round((time.perf_counter() - t_signals) * 1000, 1)
children_weights = getattr(self, "_composite_children_weights", None) or [
(cd.meta.get("id", ""), 1.0) for cd, _, _ in resolved_children
]
return composite_mod.merge_signal_matrices(
market_data.shape,
sigs,
children_weights,
merge_mode,
min_confirm,
max_hold,
)
def prepare_matrix_optimization(
self,
configs: list[StrategyBacktestConfig],
@@ -839,6 +961,14 @@ class StrategyBacktestService:
)
try:
if s.execution_backend == "composite":
# composite 回测: 子策略必须全为 matrix_native(否则 fail-closed),
# feature_plan 取所有子策略计划的并集(_merge_resolved_feature_plans)。
feature_plan, composite_children_resolved = self._resolve_composite_feature_plan(
s, params=params, basic_filter=basic_filter, overrides=overrides
)
else:
composite_children_resolved = None
feature_plan = StrategyDependencyResolver().resolve(
s,
params=params,
@@ -887,7 +1017,7 @@ class StrategyBacktestService:
matrix_data_cache_status = prepared.market_data.cache_status
matrix_data_cache_hit = matrix_data_cache_status in {"exact", "covering"}
matrix_data_cache_timing_ms = prepared.market_data.cache_timing_ms
elif s.execution_backend == "matrix_native":
elif s.execution_backend in ("matrix_native", "composite"):
t_load = time.perf_counter()
max_hold_for_profile = self._override_value(
overrides,
@@ -985,7 +1115,89 @@ class StrategyBacktestService:
t_signal = time.perf_counter()
selection_stats: dict[str, int | bool]
if s.execution_backend == "matrix_native":
if s.execution_backend == "composite":
# composite 回测信号生成: 复用 matrix 数据加载, 逐子策略算信号后合并。
# 退出采用来源投影(composite.merge_signal_matrices), 不串平其他子策略仓位。
if composite_children_resolved is None:
return _err("叠加策略子策略解析失败")
if market_data is None:
return _err("矩阵回测缺少基础行情矩阵")
entry_time_mask = self._matrix_date_range_mask(
market_data.timestamp_labels,
config.start,
config.end,
)
exit_time_mask = self._matrix_date_range_mask(
market_data.timestamp_labels,
config.start,
load_end if config.mode == "full" else config.end,
)
sim_time_mask = self._matrix_date_range_mask(
market_data.timestamp_labels,
config.start,
sim_end,
)
time_ids = np.flatnonzero(sim_time_mask)
if time_ids.size == 0:
return _err("正式回测区间内无数据")
start_id = int(time_ids[0])
stop_id = int(time_ids[-1]) + 1
panel_rows = int(np.isfinite(market_data.close[start_id:stop_id]).sum())
panel_columns = len(feature_plan.matrix_columns)
reference_price = (
rolling_mean(market_data.close, 5)[start_id:stop_id]
if matcher_config.minute_fill
else None
)
merge_mode = str(params.get("merge_mode") or "union")
min_confirm = int(params.get("min_confirm") or 0)
# max_hold 用于退出投影窗口封顶; 无值时给一个足够大的兜底(仅靠信号退出)。
composite_max_hold = max(int(max_hold_days or 0), 1) if max_hold_days else 250
try:
signal_matrix = self._generate_composite_signal_matrix(
composite_children_resolved,
market_data,
merge_mode,
min_confirm,
composite_max_hold,
timing_ms,
)
except ValueError as e:
return _err(str(e))
sim_market_data = slice_market_data_matrix(market_data, start_id, stop_id)
sim_signal_matrix = slice_signal_matrix(signal_matrix, start_id, stop_id)
sim_signal_matrix = apply_time_masks(
sim_signal_matrix,
entry_time_mask[start_id:stop_id],
exit_time_mask[start_id:stop_id],
)
timing_ms["signals_score"] = round((time.perf_counter() - t_signal) * 1000, 1)
if not sim_signal_matrix.entry.any():
return _err("在指定区间内未产生买入信号")
raw_candidates = int(sim_signal_matrix.entry.sum())
selection_stats = {
"strategy_matches": raw_candidates,
"entry_candidates": raw_candidates,
"entry_trigger_filtered": 0,
"entry_trigger_enabled": False,
}
del market_data, signal_matrix
t_matrix = time.perf_counter()
market_matrix = build_market_matrix_from_signals(
sim_market_data,
sim_signal_matrix,
entry_delay_bars=1 if matcher_config.entry_fill == "open_t+1" else 0,
exit_delay_bars=1 if matcher_config.exit_fill == "open_t+1" else 0,
reference_price=reference_price,
minute_exit_trigger=matcher_config.exit_fill == "signal_next_minute",
)
timing_ms["matrix_build"] = round((time.perf_counter() - t_matrix) * 1000, 1)
del sim_market_data, sim_signal_matrix
elif s.execution_backend == "matrix_native":
if s.matrix_strategy is None:
return _err("矩阵策略未注册")
if self._has_matrix_signal_override(s, overrides):
@@ -1227,6 +1439,19 @@ class StrategyBacktestService:
"score_max": score_max,
"source": s.source,
"execution_backend": s.execution_backend,
**(
{
"composite_children": [
{
"id": cid,
"weight": cw,
}
for cid, cw in getattr(self, "_composite_children_weights", [])
]
}
if s.execution_backend == "composite"
else {}
),
} if result_policy.include_strategy_info else {}
selected_stats = result_policy.select_stats(result.stats)
+4 -1
View File
@@ -154,7 +154,10 @@ class WalkForwardService:
return None
strategy = self.strategy_engine.get(cfg.strategy_id)
if strategy.execution_backend != "matrix_native":
return None
raise ValueError(
f"步进优化暂仅支持矩阵(matrix_native)策略; "
f"{cfg.strategy_id}{strategy.execution_backend}"
)
from app.backtest.optimizer import expand_param_grid
from app.backtest.strategy import StrategyBacktestConfig
+6 -1
View File
@@ -82,6 +82,7 @@ def _strategy_dirs(data_dir: Path) -> list[Path]:
app_dir / "strategy" / "builtin",
data_dir / "strategies" / "custom",
data_dir / "strategies" / "ai",
data_dir / "strategies" / "composite",
]
@@ -165,12 +166,16 @@ def _worker_entry(task: dict[str, Any], event_queue, cancel_event) -> None:
from app.backtest.optimizer import StrategyOptimizer
from app.backtest.strategy import StrategyBacktestService
from app.strategy.engine import StrategyEngine
from app.strategy import config as strategy_config
from app.tickflow.repository import DataStore, KlineRepository
data_dir = Path(task["data_dir"])
store = DataStore(data_dir)
repo = KlineRepository(store)
strategy_engine = StrategyEngine(strategy_dirs=_strategy_dirs(data_dir))
strategy_engine = StrategyEngine(
strategy_dirs=_strategy_dirs(data_dir),
override_loader=lambda sid: strategy_config.load_override(data_dir, sid),
)
service = StrategyBacktestService(BacktestEngine(repo), strategy_engine)
def _progress(message: dict) -> None:
+3
View File
@@ -141,6 +141,7 @@ async def lifespan(app: FastAPI):
# 策略引擎
from app.strategy.engine import StrategyEngine
from app.strategy import config as strategy_config
from app.strategy.monitor import StrategyMonitorService
from app.services.screener import ScreenerService
@@ -150,9 +151,11 @@ async def lifespan(app: FastAPI):
Path(__file__).resolve().parent / "strategy" / "builtin",
store.data_dir / "strategies" / "custom",
store.data_dir / "strategies" / "ai",
store.data_dir / "strategies" / "composite",
]
strategy_engine = StrategyEngine(
strategy_dirs=strategy_dirs,
override_loader=lambda sid: strategy_config.load_override(store.data_dir, sid),
)
app.state.strategy_engine = strategy_engine
logger.info("strategy engine loaded: %d strategies", len(strategy_engine.list_strategies()))
+262
View File
@@ -0,0 +1,262 @@
"""叠加策略合并器 — 选股与回测共用的纯函数。
为什么单独成模块: 选股(StrategyEngine._run_composite_strategy)和回测
(StrategyBacktestService)都要合并子策略结果, 必须共享同一套口径, 否则会出现
"选股与回测使用不同逻辑"的金融错误(CONTRIBUTING §5.1)。
两种合并入口:
- merge_results: 选股合并。输入各子 StrategyResult, 输出合并后的 StrategyResult。
- merge_signal_matrices: 回测合并。输入各子 SignalMatrix, 输出合并后的 SignalMatrix。
合并语义(首版):
- entry: union=OR(entries); intersect=Σ(entries) >= min_confirm
- score: 各子内部按 score 降序排名归一到 [0,1], 命中子策略间按权重加权。
排名是相对位置, 跨子策略天然可比, 不依赖各子 per-strategy 的 min-max 量纲。
- exit(回测): 来源投影。每个子策略 i 的 exit 仅在它自己 entry 后的持仓窗口内生效,
避免"B 的退出信号平掉 A 的仓位"。窗口由全局 max_hold 封顶。
退出投影的金融正确性: 现有撮合引擎(engine.py:993)读全局 matrix.exit, 不区分来源。
若直接 OR(exit) 会产生"幽灵平仓"(B 把 A 选的仓位卖掉)。来源投影在合并器(撮合前)
解决, 撮合层零改动。
"""
from __future__ import annotations
from datetime import date
from typing import TYPE_CHECKING
import numpy as np
if TYPE_CHECKING:
from app.strategy.engine import StrategyResult
# 中性分: 子策略无 score 或单候选(无法排名)时的占位, 不污染融合结果。
_NEUTRAL_NORM = 0.5
def _effective_weights(
children_weights: list[float],
hits_mask: list[bool],
) -> tuple[list[float], float]:
"""从权重列表中筛出命中子策略的权重, 返回(命中权重列表, 命中权重总和)。"""
effective = [w for w, hit in zip(children_weights, hits_mask, strict=True) if hit]
total = sum(effective)
return effective, total
def merge_results(
results: list[StrategyResult],
children_weights: list[float],
merge_mode: str,
min_confirm: int,
*,
as_of: date,
strategy_id: str,
elapsed_ms: float = 0.0,
) -> StrategyResult:
"""选股合并: 按 symbol 聚合各子结果, 标准化排名加权融合 score。
Args:
results: 各子策略的 StrategyResult(顺序与 children_weights 对齐)
children_weights: 各子权重(顺序对齐)
merge_mode: "union"(任一命中即入选) | "intersect"(至少 min_confirm 个命中)
min_confirm: intersect 模式下命中的最少子策略数; <=0 视为全部子策略
as_of / strategy_id: 合并结果归属(composite 自身)
"""
from app.strategy.engine import StrategyResult
n_children = len(results)
if n_children == 0:
return StrategyResult(as_of=as_of, strategy_id=strategy_id, elapsed_ms=elapsed_ms)
# 各子的 symbol → 排名归一 score。排名基于子策略内部的原始 score 降序。
# norm∈[0,1], 最优标的=1。单候选或无 score 时用中性分。
per_child_norm: list[dict[str, float]] = []
per_child_symbols: list[set[str]] = []
for res in results:
symbols = set(res.scores.keys())
per_child_symbols.append(symbols)
norm: dict[str, float] = {}
if symbols:
ordered = sorted(symbols, key=lambda s: res.scores[s], reverse=True)
count = len(ordered)
for rank, sym in enumerate(ordered, start=1):
norm[sym] = 1 - (rank - 1) / max(count - 1, 1)
else:
# 子策略未产出 score: 命中即中性分, 不奖励也不惩罚。
for row in res.rows:
sym = str(row.get("symbol"))
if sym and sym not in norm:
norm[sym] = _NEUTRAL_NORM
per_child_norm.append(norm)
# 确定入围标的集合 + 各标的的命中子策略索引。
universe: set[str] = set()
for syms in per_child_symbols:
universe.update(syms)
# 也纳入 rows 里有但 scores 里没有的标的(子策略无 score 但产出行)。
for res in results:
for row in res.rows:
sym = str(row.get("symbol"))
if sym:
universe.add(sym)
effective_min = max(min_confirm, 1) if min_confirm and min_confirm > 0 else n_children
scores: dict[str, float] = {}
for sym in universe:
hits = [i for i in range(n_children) if sym in per_child_norm[i]]
if not hits:
continue
if merge_mode == "intersect" and len(hits) < effective_min:
continue
weights, total_w = _effective_weights(
children_weights, [i in hits for i in range(n_children)]
)
if total_w <= 0:
# 全部权重为 0: 退化为均等。
total_w = float(len(hits))
weights = [1.0] * len(hits)
blended = sum(w * per_child_norm[i][sym] for i, w in zip(hits, weights, strict=True))
scores[sym] = round(blended / total_w * 100, 4)
total = len(scores)
return StrategyResult(
as_of=as_of,
strategy_id=strategy_id,
rows=[],
total=total,
elapsed_ms=elapsed_ms,
scores=scores,
)
def _hold_masks_from_entries(entries: list[np.ndarray], max_hold: int) -> list[np.ndarray]:
"""计算每个子策略的持仓窗口掩码。
hold_mask_i[t, a] = True 当且仅当存在 t' <= t 使 entry_i[t', a] 触发,
且 t - t' < max_hold(即仍在最长持仓期内, 未被 max_hold 强制平仓)。
实现用前向填充: 从每个 entry 起向前扩展 max_hold-1 个 bar 为 True。
"""
if max_hold <= 0:
max_hold = 1
masks: list[np.ndarray] = []
for entry in entries:
raw = entry.astype(bool, copy=False)
# 对每个 asset 列, 把 True 向前传播 max_hold 个 bar。
# 用按行位移 OR 实现: mask[t] |= raw[t-k] for k in [0, max_hold-1]
mask = np.zeros_like(raw)
window = raw.copy()
mask |= window
for _k in range(1, max_hold):
window = np.roll(window, 1, axis=0)
window[0, :] = False # roll 会在顶部环绕, 置零防未来泄漏
mask |= window
masks.append(mask)
return masks
def merge_signal_matrices(
shape: tuple[int, int],
sigs: list,
children: list[tuple[str, float]],
merge_mode: str,
min_confirm: int,
max_hold: int,
):
"""回测合并: 产出合并 entry/exit/score/entry_signal_code 矩阵。
Args:
shape: (n_times, n_assets)
sigs: 各子策略 SignalMatrix(顺序与 children 对齐)
children: [(strategy_id, weight), ...]
merge_mode / min_confirm: 同 merge_results
max_hold: 全局最长持仓天数, 用于退出投影窗口封顶
返回合并后的 SignalMatrix。
"""
from app.backtest.matrix import make_signal_matrix
n_times, n_assets = shape
n_children = len(sigs)
if n_children == 0:
return make_signal_matrix(shape)
# ── entry ──
entries = [np.asarray(s.entry, dtype=bool) for s in sigs]
entry_stack = np.stack(entries, axis=0) # (n_children, n_times, n_assets)
if merge_mode == "intersect":
effective_min = max(min_confirm, 1) if min_confirm and min_confirm > 0 else n_children
confirm_count = entry_stack.sum(axis=0) # (n_times, n_assets)
merged_entry = confirm_count >= effective_min
else: # union
merged_entry = entry_stack.any(axis=0)
# ── exit 来源投影 ──
# 每个子策略的 exit 仅在自己持仓窗口内生效, 不串平其他子的仓位。
hold_masks = _hold_masks_from_entries(entries, max_hold)
merged_exit = np.zeros(shape, dtype=bool)
for i, s in enumerate(sigs):
child_exit = np.asarray(s.exit, dtype=bool)
merged_exit |= child_exit & hold_masks[i]
# ── score 标准化排名加权 ──
# 对每个 (time, asset), 在命中的子策略间按权重加权各自的内部排名归一值。
weights = np.array([w for _, w in children], dtype=np.float64)
# 预计算每个子策略、每个 time 上 asset 的排名归一。
norm_scores = np.zeros((n_children, n_times, n_assets), dtype=np.float64)
for i, s in enumerate(sigs):
raw = np.asarray(s.score, dtype=np.float64)
for t in range(n_times):
row = raw[t]
hit = entries[i][t]
if not hit.any():
continue
hit_idx = np.flatnonzero(hit)
hit_vals = row[hit_idx]
finite = np.isfinite(hit_vals)
if not finite.any():
norm_scores[i, t, hit_idx[finite]] = _NEUTRAL_NORM
continue
valid_idx = hit_idx[finite]
valid_vals = hit_vals[finite]
n = len(valid_idx)
if n <= 1:
norm_scores[i, t, valid_idx] = _NEUTRAL_NORM
continue
# 降序排名: 最优=1, 最差=0。
order = np.argsort(-valid_vals, kind="stable")
ranks = np.empty(n, dtype=np.float64)
ranks[order] = np.arange(1, n + 1, dtype=np.float64)
normalized = 1 - (ranks - 1) / (n - 1)
norm_scores[i, t, valid_idx] = normalized
hit_mask = entry_stack # (n_children, n_times, n_assets)
hit_weights = np.where(hit_mask, weights.reshape(-1, 1, 1), 0.0)
weight_sum = hit_weights.sum(axis=0) # (n_times, n_assets)
blended = (norm_scores * hit_weights).sum(axis=0)
safe_sum = np.where(weight_sum > 0, weight_sum, 1.0)
merged_score = np.where(merged_entry, blended / safe_sum, 0.0) * 100
merged_score = np.nan_to_num(merged_score, nan=0.0, posinf=0.0, neginf=0.0)
# ── entry_signal_code: 来源标记 ──
# code = 命中的第一个子策略索引; -1 表示无命中。归因用。
entry_codes = np.full(shape, -1, dtype=np.int16)
for i in range(n_children):
# 只给尚未标记的命中点打 code(第一个命中优先), 避免覆盖。
untagged = (entry_codes == -1) & entries[i]
entry_codes[untagged] = i
# exit_signal_code 沿用 -1(无独立信号来源); 回测按 "signal" reason 归因即可。
exit_codes = np.full(shape, -1, dtype=np.int16)
# entry_signal_ids 映射表: code i → "composite:child_id"
entry_signal_ids = tuple(f"composite:{cid}" for cid, _ in children)
return make_signal_matrix(
shape,
entry=merged_entry.astype(np.uint8),
exit=merged_exit.astype(np.uint8),
score=merged_score.astype(np.float32),
entry_signal_code=entry_codes,
exit_signal_code=exit_codes,
entry_signal_ids=entry_signal_ids,
)
+295 -3
View File
@@ -39,6 +39,9 @@ DEFAULT_BASIC_FILTER: dict = {
"boards": ["沪主板", "深主板", "创业板", "科创板", "北交所"],
}
# 叠加策略硬上限:子策略数量。控制信号计算成本与字段并集膨胀,避免 OOM。
MAX_COMPOSITE_CHILDREN = 8
def _normalize_param_defs(params: Any) -> list[dict]:
"""把 META["params"] 归一化为标准 list[dict] (每项含 id/label/type/default).
@@ -98,6 +101,42 @@ def _normalize_param_item(item: dict) -> dict:
return norm
def _parse_composite_children(raw: Any) -> CompositeSpec:
"""解析 META["children"] 为 CompositeSpec。
每项形如 {"strategy_id": "xxx", "weight": 0.4}。仅做结构和权重校验:
- 非空 list, 每项含合法 strategy_id 与非负 weight
- 数量 <= MAX_COMPOSITE_CHILDREN (超出在加载期拒绝, 避免信号计算成本爆炸)
子策略的存在性/非嵌套/asset_types 一致性由 _load_all 两阶段校验保证。
"""
if not isinstance(raw, list) or not raw:
raise ValueError("composite strategy META['children'] must be a non-empty list")
if len(raw) > MAX_COMPOSITE_CHILDREN:
raise ValueError(
f"composite strategy children count {len(raw)} exceeds limit {MAX_COMPOSITE_CHILDREN}"
)
children: list[CompositeChild] = []
seen: set[str] = set()
for i, item in enumerate(raw):
if not isinstance(item, dict):
raise ValueError(f"composite children[{i}] must be a dict")
cid = item.get("strategy_id")
if not isinstance(cid, str) or not cid:
raise ValueError(f"composite children[{i}] missing non-empty 'strategy_id'")
if cid in seen:
raise ValueError(f"composite children[{i}] duplicate strategy_id {cid!r}")
seen.add(cid)
weight = item.get("weight", 1.0)
try:
weight = float(weight)
except (TypeError, ValueError) as e:
raise ValueError(f"composite children[{i}] weight must be a number") from e
if weight < 0:
raise ValueError(f"composite children[{i}] weight must be >= 0")
children.append(CompositeChild(strategy_id=cid, weight=weight))
return CompositeSpec(children=tuple(children))
@dataclass
class StrategyDataContext:
"""一次策略调用所需的标准数据上下文。"""
@@ -111,6 +150,25 @@ class StrategyDataContext:
cache_key: str | None = None
@dataclass(frozen=True)
class CompositeChild:
"""叠加策略的一个子策略引用。"""
strategy_id: str
weight: float
@dataclass(frozen=True)
class CompositeSpec:
"""叠加策略的子策略声明(无业务代码,仅引用与权重)。
引用合法性与一致性由 _load_all 两阶段校验保证:子策略必须存在、
非嵌套、asset_types 一致、数量 ≤ MAX_COMPOSITE_CHILDREN。
"""
children: tuple[CompositeChild, ...]
@dataclass
class StrategyDef:
"""加载后的策略定义(只读数据 + filter 函数引用)"""
@@ -127,11 +185,12 @@ class StrategyDef:
filter_fn: Callable[[pl.DataFrame, dict], pl.Expr] | None
filter_history_fn: Callable[[pl.DataFrame, dict], pl.DataFrame] | None
lookback_days: int
source: str # "builtin" | "custom" | "ai"
source: str # "builtin" | "custom" | "ai" | "composite"
required_features: frozenset[str] = field(default_factory=frozenset)
file_path: Path | None = None
execution_backend: str = "polars_expr"
matrix_strategy: Any | None = None
composite: CompositeSpec | None = None # 仅 backend=="composite" 时非空
@dataclass
@@ -158,12 +217,21 @@ class StrategyEngine:
_module_load_lock = threading.RLock()
def __init__(self, strategy_dirs: list[Path] | None = None):
def __init__(
self,
strategy_dirs: list[Path] | None = None,
*,
override_loader: Callable[[str], dict] | None = None,
):
self._strategies: dict[str, StrategyDef] = {}
self._load_errors: list[dict] = [] # 加载失败的策略 [{file, error}]
self._strategy_dirs = strategy_dirs or []
self._realtime_matrices: dict[str, _RealtimeMatrixEntry] = {}
self._realtime_matrix_lock = threading.RLock()
# 可选的 override 加载器: 叠加策略执行时用它查子策略的用户覆盖配置,
# 保证 composite 内跑子策略与单独跑子策略使用同一口径(CONTRIBUTING §5.1)。
# None 时(测试/无 data_dir) 子策略用默认参数, 不报错。
self._override_loader = override_loader
self._load_all(retain_previous_on_error=False)
# ================================================================
@@ -206,6 +274,24 @@ class StrategyEngine:
logger.warning("load strategy %s failed: %s", f.name, e)
errors.append({"file": str(f), "error": str(e)})
# 第二阶段: 校验 composite 引用合法性。
# _load_file 是 staticmethod, 加载单个文件时无法判断 child 是否存在;
# 此处已拿到全部 candidates, 可做引用、嵌套、asset_types 与数量校验。
# 孤儿 composite (引用不合法) 被移出 candidates 并记入 errors,
# 但不触发整体 reload 失败 —— 不波及其他正常策略(插件隔离原则)。
for sid in list(candidates):
strategy = candidates[sid]
if strategy.execution_backend != "composite" or strategy.composite is None:
continue
error = self._validate_composite_references(sid, strategy, candidates)
if error is not None:
errors.append({
"file": str(strategy.file_path) if strategy.file_path else sid,
"error": error,
})
candidates.pop(sid, None)
candidate_paths.pop(sid, None)
self._load_errors = errors
if errors and retain_previous_on_error:
return False
@@ -219,6 +305,53 @@ class StrategyEngine:
"""返回最近一次 _load_all 中加载失败的策略 [{file, error}]。"""
return list(self._load_errors)
@staticmethod
def _validate_composite_references(
sid: str,
strategy: StrategyDef,
candidates: dict[str, StrategyDef],
) -> str | None:
"""校验 composite 策略的引用合法性。返回错误描述或 None。
规则(首版硬约束):
- 每个 child 必须已加载(candidates 中存在)
- 禁止 composite 嵌套 composite(子策略必须是叶子)
- child 的 asset_types / timeframes 必须与父 composite 完全一致
- 数量 <= MAX_COMPOSITE_CHILDREN
任一不满足返回错误描述, 由 _load_all 移除该孤儿策略(不波及无辜)。
"""
assert strategy.composite is not None
children = strategy.composite.children
if len(children) > MAX_COMPOSITE_CHILDREN:
return (
f"composite strategy {sid} children count {len(children)} "
f"exceeds limit {MAX_COMPOSITE_CHILDREN}"
)
parent_assets = list(strategy.meta.get("asset_types", ["stock"]))
parent_timeframes = list(strategy.meta.get("timeframes", ["1d"]))
for child in children:
child_def = candidates.get(child.strategy_id)
if child_def is None:
return f"composite strategy {sid} 引用的子策略 {child.strategy_id!r} 不存在"
if child_def.execution_backend == "composite":
return (
f"composite strategy {sid} 引用的子策略 {child.strategy_id!r} "
f"也是叠加策略; 首版禁止嵌套叠加"
)
child_assets = list(child_def.meta.get("asset_types", ["stock"]))
if not set(parent_assets).issubset(set(child_assets)):
return (
f"composite strategy {sid} 的 asset_types {parent_assets} "
f"未被子策略 {child.strategy_id!r} 完全支持(支持 {child_assets})"
)
child_timeframes = list(child_def.meta.get("timeframes", ["1d"]))
if not set(parent_timeframes).issubset(set(child_timeframes)):
return (
f"composite strategy {sid} 的 timeframes {parent_timeframes} "
f"未被子策略 {child.strategy_id!r} 完全支持(支持 {child_timeframes})"
)
return None
@staticmethod
def _load_file(path: Path) -> StrategyDef:
"""从 Python 文件加载策略定义"""
@@ -295,6 +428,8 @@ class StrategyEngine:
source = "builtin"
elif "/ai/" in normalized_path:
source = "ai"
elif "/composite/" in normalized_path:
source = "composite"
if source == "builtin" and "asset_types" not in meta:
raise ValueError("builtin strategy META must declare asset_types")
@@ -338,7 +473,7 @@ class StrategyEngine:
),
)
)
valid_backends = {"polars_expr", "matrix_native", "python_history_legacy"}
valid_backends = {"polars_expr", "matrix_native", "python_history_legacy", "composite"}
if execution_backend not in valid_backends:
raise ValueError(
f"unsupported execution backend {execution_backend!r}; "
@@ -346,6 +481,7 @@ class StrategyEngine:
)
matrix_strategy = getattr(mod, "MATRIX_STRATEGY", None)
composite_spec: CompositeSpec | None = None
if execution_backend == "matrix_native":
from app.backtest.matrix import MatrixStrategy
@@ -358,6 +494,19 @@ class StrategyEngine:
elif execution_backend == "polars_expr":
if filter_fn is None or filter_history_fn is not None:
raise ValueError("polars_expr strategy must declare only filter")
elif execution_backend == "composite":
# 叠加策略是声明式的: 不含业务代码, 仅通过 META["children"] 引用其他策略。
# 引用合法性(子策略存在/非嵌套/asset_types 一致/数量上限)延后到
# _load_all 两阶段校验 —— 因为此时注册表尚未加载完, 无法判断 child 是否存在。
if (
filter_fn is not None
or filter_history_fn is not None
or matrix_strategy is not None
):
raise ValueError(
"composite strategy must not declare filter, filter_history or MATRIX_STRATEGY"
)
composite_spec = _parse_composite_children(meta.get("children"))
elif filter_history_fn is None or filter_fn is not None:
raise ValueError("python_history_legacy strategy must declare only filter_history")
@@ -381,6 +530,7 @@ class StrategyEngine:
file_path=path,
execution_backend=execution_backend,
matrix_strategy=matrix_strategy,
composite=composite_spec,
)
def reload(self) -> None:
@@ -432,6 +582,21 @@ class StrategyEngine:
self._realtime_matrices.clear()
return True
def find_dependents(self, strategy_id: str) -> list[str]:
"""返回引用了 strategy_id 作为子策略的所有 composite 策略 id。
供删除校验使用: 删除被引用的子策略会令 composite 加载失败,
删除前应阻止(fail-closed)或提示用户先解除引用。策略数量通常很小,
线性遍历注册表即可, 无需维护反向索引。
"""
dependents: list[str] = []
for sid, strategy in self._strategies.items():
if strategy.execution_backend != "composite" or strategy.composite is None:
continue
if any(c.strategy_id == strategy_id for c in strategy.composite.children):
dependents.append(sid)
return dependents
@staticmethod
def validate_context(strategy: StrategyDef, context: StrategyDataContext) -> None:
asset_types = strategy.meta.get("asset_types", ["stock"])
@@ -500,6 +665,16 @@ class StrategyEngine:
required,
int(strategy.matrix_strategy.required_warmup_bars(params)) + 1,
)
elif strategy.execution_backend == "composite":
# composite 预热 = 各子策略预热的 max。
# 子策略已通过加载期校验(非嵌套叶子), 这里展开一层即可。
if strategy.composite is None:
continue
child_ids = [c.strategy_id for c in strategy.composite.children]
required = max(
required,
self.required_history_bars(child_ids, params_map=params_map),
)
elif strategy.filter_history_fn:
required = max(required, int(strategy.lookback_days))
return required
@@ -646,6 +821,17 @@ class StrategyEngine:
started_at=t0,
)
if s.execution_backend == "composite":
return self._run_composite_strategy(
strategy_id,
s,
context,
pool=pool,
params=params,
overrides=overrides,
started_at=t0,
)
signal_df = context.current if context.current is not None else context.history
if signal_df is None:
signal_df = pl.DataFrame()
@@ -998,6 +1184,112 @@ class StrategyEngine:
exit_signal_hits=exit_signal_hits,
)
def _run_composite_strategy(
self,
strategy_id: str,
strategy: StrategyDef,
context: StrategyDataContext,
*,
pool: list[str] | None = None,
params: dict | None = None,
overrides: dict | None = None,
started_at: float,
) -> StrategyResult:
"""叠加策略选股: 调度各子策略(共享 context)→ 合并结果。
复用 run_all 共享 current/history/market, 避免各子策略重复加载数据。
子策略必须已在加载期通过两阶段引用校验(存在/非嵌套/asset_types 一致)。
"""
from app.strategy import composite as composite_mod
assert strategy.composite is not None
overrides = overrides or {}
# 权重: override.children 优先(META 固化值的轻量覆盖), 否则用 META 声明。
override_children = overrides.get("children")
if isinstance(override_children, list) and override_children:
spec = _parse_composite_children(override_children)
children = spec.children
else:
children = strategy.composite.children
child_ids = [c.strategy_id for c in children]
child_weights = [c.weight for c in children]
merge_mode = str(params.get("merge_mode") or "union")
min_confirm = int(params.get("min_confirm") or 0)
# 子策略 override: 先加载各自保存的用户配置(参数/评分/信号等),
# 再叠加 composite 统一的 basic_filter(计划 §3.3, 保证候选池一致)。
# 这样 composite 内跑子策略与单独跑子策略使用同一口径。
shared_basic_filter = overrides.get("basic_filter")
overrides_map: dict[str, dict] = {}
for cid in child_ids:
child_override: dict = {}
if self._override_loader is not None:
try:
loaded = self._override_loader(cid)
if isinstance(loaded, dict):
child_override = dict(loaded)
except Exception: # noqa: BLE001
pass
if shared_basic_filter:
child_override["basic_filter"] = shared_basic_filter
overrides_map[cid] = child_override
# 共享 context 跑所有子策略。run_all 内部对 matrix_native 子策略会
# 合并 field_columns 构建超集矩阵, 一次加载。
child_results = self.run_all(
context,
params_map={},
overrides_map=overrides_map,
strategy_ids=child_ids,
)
ordered_results = [child_results[cid] for cid in child_ids]
merged = composite_mod.merge_results(
ordered_results,
child_weights,
merge_mode,
min_confirm,
as_of=context.as_of,
strategy_id=strategy_id,
)
# 构造展示行: 按 symbol 从各子结果取首个命中的行(含 name/价格等展示字段),
# 融合 score。子策略间 schema 可能不同, 保留首个命中子的字段即可。
row_by_symbol: dict[str, dict] = {}
for res in ordered_results:
for row in res.rows:
sym = str(row.get("symbol"))
if sym and sym not in row_by_symbol and sym in merged.scores:
row_by_symbol[sym] = row
order_desc = bool(strategy.meta.get("descending", True))
ranked_symbols = sorted(
merged.scores.keys(),
key=lambda s: merged.scores[s],
reverse=order_desc,
)
limit = self._result_limit(strategy, overrides)
if limit is not None:
ranked_symbols = ranked_symbols[:limit]
rows = _sanitize([
{**row_by_symbol[sym], "score": merged.scores[sym]}
for sym in ranked_symbols
if sym in row_by_symbol
])
scores = {str(row["symbol"]): float(row.get("score") or 0.0) for row in rows}
return StrategyResult(
as_of=context.as_of,
strategy_id=strategy_id,
rows=rows,
total=len(rows),
elapsed_ms=(time.perf_counter() - started_at) * 1000,
scores=scores,
)
@staticmethod
def _matrix_signal_hits(
active: np.ndarray,
+6
View File
@@ -797,6 +797,12 @@ class MonitorRuleEngine:
as_of=cn_today(),
current=df,
)
if getattr(s, "execution_backend", "polars_expr") == "composite":
# 叠加策略首版不支持实时监控: 各子策略需独立预热实时矩阵, 热路径成本为 N 倍,
# 违反"实时热路径不得随历史数据量线性增长"约束。fail-closed 跳过本轮,
# /cached 端点回退到盘后 strategy_cache.json 的批量结果。
logger.debug("叠加策略 %s 暂不支持实时监控, 跳过", sid)
return []
if getattr(s, "execution_backend", "polars_expr") == "matrix_native":
matrix = self._active_matrix_snapshots.get(at)
if matrix is None:
@@ -0,0 +1,170 @@
"""叠加策略回测端到端集成测试 (M2 验证)。
用真实的内置 matrix_native 子策略(ma_golden_cross + macd_golden) + 合成行情 panel,
完整跑通 StrategyBacktestService.run() 的 composite 分支:
特征计划合并 → 超集矩阵加载 → 逐子策略信号 → 合并(退出投影) → 撮合 → 结果归因。
验证两种模式(position/full)和两种合并模式(union/intersect)的关键路径。
合成数据用上涨趋势制造金叉信号。
"""
from __future__ import annotations
from datetime import date, timedelta
from pathlib import Path
import numpy as np
import polars as pl
import pytest
from app.backtest.engine import BacktestEngine
from app.backtest.matrix import build_market_data_matrix
from app.backtest.strategy import StrategyBacktestConfig, StrategyBacktestService
from app.strategy.engine import StrategyEngine
def _synthetic_panel() -> pl.DataFrame:
"""构造上涨趋势的合成 panel: 前 50 天横盘, 后 50 天上涨, 制造金叉信号。"""
np.random.seed(42)
n_days = 100
symbols = ["000001.SZ", "000002.SZ", "600000.SH"]
rows = []
for sym_idx, sym in enumerate(symbols):
base = 10.0 + sym_idx * 5
for t in range(n_days):
d = date(2025, 1, 2) + timedelta(days=int(t * 1.5))
if t < 50:
close = base + np.random.uniform(-0.3, 0.3)
else:
close = base * (1 + (t - 50) * 0.015) + np.random.uniform(-0.3, 0.3)
open_p = close + np.random.uniform(-0.2, 0.2)
high = max(open_p, close) + np.random.uniform(0.1, 0.5)
low = min(open_p, close) - np.random.uniform(0.1, 0.5)
vol = float(np.random.uniform(1e6, 5e6))
rows.append({
"symbol": sym, "date": d, "open": open_p, "high": high,
"low": low, "close": close, "volume": vol,
"raw_close": close, "raw_high": high, "raw_low": low,
})
return pl.DataFrame(rows).sort(["symbol", "date"])
class _RepoStub:
def get_index_daily(self, *args, **kwargs) -> pl.DataFrame:
return pl.DataFrame()
def _make_service(panel: pl.DataFrame, builtin: Path, comp_dir: Path):
"""构造 StrategyBacktestService, 用 monkeypatch 的数据加载。"""
bt_engine = BacktestEngine(_RepoStub())
def _load_mdm(self, symbols, start, end, feature_plan, asset_type="stock", **kw):
df = panel.filter((pl.col("date") >= start) & (pl.col("date") <= end))
if symbols:
df = df.filter(pl.col("symbol").is_in(symbols))
cols = [c for c in sorted(set(feature_plan.base_columns)) if c in df.columns]
return build_market_data_matrix(df.select(cols), field_columns=feature_plan.matrix_columns)
bt_engine.load_market_data_matrix_for_backtest = _load_mdm.__get__(bt_engine)
strategy_engine = StrategyEngine(strategy_dirs=[builtin, comp_dir])
return StrategyBacktestService(bt_engine, strategy_engine)
def _write_composite(
comp_dir: Path, sid: str, children: list, *, merge_mode="union", min_confirm=0
):
children_repr = ", ".join(
f'{{"strategy_id": "{c}", "weight": {w}}}' for c, w in children
)
opts = '["union", "intersect"]'
params_block = (
f'{{"id": "merge_mode", "type": "select", '
f'"options": {opts}, "default": "{merge_mode}"}}, '
f'{{"id": "min_confirm", "type": "int", "default": {min_confirm}}}'
)
(comp_dir / f"{sid}.py").write_text(
f'''META = {{"id": "{sid}", "name": "{sid}", "asset_types": ["stock"], "timeframes": ["1d"],
"params": [{params_block}], "children": [{children_repr}]}}
EXECUTION_BACKEND = "composite"
''',
encoding="utf-8",
)
@pytest.fixture
def composite_setup(tmp_path):
builtin = Path(__file__).resolve().parents[2] / "app" / "strategy" / "builtin"
comp_dir = tmp_path / "composite"
comp_dir.mkdir(parents=True)
_write_composite(comp_dir, "custom_e2e_blend", [("ma_golden_cross", 0.5), ("macd_golden", 0.5)])
panel = _synthetic_panel()
svc = _make_service(panel, builtin, comp_dir)
return svc, comp_dir
def test_composite_backtest_position_mode(composite_setup):
"""position 模式: composite 回测产出交易、净值曲线和归因。"""
svc, _ = composite_setup
cfg = StrategyBacktestConfig(
strategy_id="custom_e2e_blend", symbols=None,
start=date(2025, 2, 1), end=date(2025, 5, 1),
mode="position", max_positions=5, holding_days=10,
overrides={"basic_filter": {"enabled": False}},
)
result = svc.run(cfg)
assert result.error is None, f"回测失败: {result.error}"
assert len(result.trades) > 0, "应产出交易"
assert len(result.equity_curve) > 0, "应有净值曲线"
assert result.strategy_info is not None
children = result.strategy_info.get("composite_children")
assert children is not None and len(children) == 2
assert {c["id"] for c in children} == {"ma_golden_cross", "macd_golden"}
def test_composite_backtest_full_mode(composite_setup):
"""full 模式(独立候选): composite 回测能跑通。"""
svc, _ = composite_setup
cfg = StrategyBacktestConfig(
strategy_id="custom_e2e_blend", symbols=None,
start=date(2025, 2, 1), end=date(2025, 5, 1),
mode="full", holding_days=10,
overrides={"basic_filter": {"enabled": False}},
)
result = svc.run(cfg)
assert result.error is None, f"full 模式失败: {result.error}"
assert len(result.trades) > 0
def test_composite_backtest_intersect_min1_equals_union(composite_setup):
"""intersect min_confirm=1 应等价于 union(都产出交易)。"""
svc, comp_dir = composite_setup
_write_composite(
comp_dir, "custom_e2e_blend",
[("ma_golden_cross", 0.5), ("macd_golden", 0.5)],
merge_mode="intersect", min_confirm=1,
)
svc.strategy_engine.reload()
cfg = StrategyBacktestConfig(
strategy_id="custom_e2e_blend", symbols=None,
start=date(2025, 2, 1), end=date(2025, 5, 1),
mode="position", max_positions=5, holding_days=10,
overrides={"basic_filter": {"enabled": False}},
)
result = svc.run(cfg)
assert result.error is None, f"intersect 模式失败: {result.error}"
assert len(result.trades) > 0, "min_confirm=1 应产出交易(等价 union)"
def test_worker_strategy_dirs_includes_composite(tmp_path):
"""回归: 回测 worker 子进程重建引擎时必须扫描 composite 目录。
缺陷历史: worker._strategy_dirs 曾遗漏 composite 目录, 导致回测子进程
找不到 composite 策略(报 unknown strategy)。main.py 与 worker.py 必须一致。
"""
from app.backtest.worker import _strategy_dirs
dirs = _strategy_dirs(tmp_path)
dir_names = [d.name for d in dirs]
assert "builtin" in dir_names
assert "composite" in dir_names, f"worker._strategy_dirs 缺 composite 目录: {dir_names}"
assert "custom" in dir_names
assert "ai" in dir_names
+124
View File
@@ -0,0 +1,124 @@
"""叠加策略子策略 override 透传测试。
验证修复: composite 执行时子策略应加载用户保存的 override(参数/评分等),
否则 composite 内跑子策略与单独跑子策略使用不同口径(CONTRIBUTING §5.1)。
场景: 子策略 filter 用 params["min_close"] 阈值。
- 默认 min_close=0 → 全选
- override 改 min_close=100 → 只选 close>100 的标的
验证 composite(引用该子策略)在有 override_loader 时使用 override 后的阈值。
"""
from __future__ import annotations
from datetime import date
import polars as pl
from app.strategy.engine import StrategyDataContext, StrategyEngine
def _param_filter_code(strategy_id: str) -> str:
"""子策略: filter 用 params["min_close"] 阈值, 命中 close > min_close。"""
return f'''import polars as pl
META = {{
"id": "{strategy_id}",
"name": "{strategy_id}",
"asset_types": ["stock"],
"timeframes": ["1d"],
"params": [{{"id": "min_close", "type": "float", "default": 0}}],
}}
EXECUTION_BACKEND = "polars_expr"
def filter(df, params):
return pl.col("close") > params.get("min_close", 0)
'''
def _composite_code(strategy_id: str, child_id: str) -> str:
return f'''META = {{
"id": "{strategy_id}",
"name": "{strategy_id}",
"asset_types": ["stock"],
"timeframes": ["1d"],
"params": [],
"children": [{{"strategy_id": "{child_id}", "weight": 1.0}}],
}}
EXECUTION_BACKEND = "composite"
'''
def _panel() -> pl.DataFrame:
return pl.DataFrame({
"symbol": ["LOW.SZ", "HIGH.SH"],
"date": [date(2026, 1, 2)] * 2,
"close": [10.0, 200.0],
})
def test_composite_child_uses_default_params_without_loader(tmp_path):
"""无 override_loader 时, 子策略用默认参数(min_close=0 → 全选)。"""
custom_dir = tmp_path / "strategies" / "custom"
comp_dir = tmp_path / "strategies" / "composite"
custom_dir.mkdir(parents=True)
comp_dir.mkdir(parents=True)
(custom_dir / "thresh.py").write_text(_param_filter_code("thresh"), encoding="utf-8")
(comp_dir / "composite_blend.py").write_text(_composite_code("composite_blend", "thresh"), encoding="utf-8")
engine = StrategyEngine(strategy_dirs=[custom_dir, comp_dir]) # 无 override_loader
ctx = StrategyDataContext(
asset_type="stock", timeframe="1d", as_of=date(2026, 1, 2),
current=_panel(),
)
result = engine.run("composite_blend", ctx, overrides={"basic_filter": {"enabled": False}})
# 默认 min_close=0 → 两个标的都命中
assert {"LOW.SZ", "HIGH.SH"} == {r["symbol"] for r in result.rows}
def test_composite_child_uses_override_params_with_loader(tmp_path):
"""有 override_loader 时, 子策略用用户 override 的参数(min_close=100 → 只选 HIGH)。"""
custom_dir = tmp_path / "strategies" / "custom"
comp_dir = tmp_path / "strategies" / "composite"
custom_dir.mkdir(parents=True)
comp_dir.mkdir(parents=True)
(custom_dir / "thresh.py").write_text(_param_filter_code("thresh"), encoding="utf-8")
(comp_dir / "composite_blend.py").write_text(_composite_code("composite_blend", "thresh"), encoding="utf-8")
# override_loader 返回 thresh 的 override: min_close=100
overrides_store = {"thresh": {"params": {"min_close": 100}}}
engine = StrategyEngine(
strategy_dirs=[custom_dir, comp_dir],
override_loader=lambda sid: overrides_store.get(sid, {}),
)
ctx = StrategyDataContext(
asset_type="stock", timeframe="1d", as_of=date(2026, 1, 2),
current=_panel(),
)
result = engine.run("composite_blend", ctx, overrides={"basic_filter": {"enabled": False}})
# override 后 min_close=100 → 只有 HIGH.SH(close=200) 命中, LOW.SZ(close=10) 被过滤
symbols = {r["symbol"] for r in result.rows}
assert "HIGH.SH" in symbols
assert "LOW.SZ" not in symbols, "子策略 override 的 min_close=100 应过滤掉 close=10 的标的"
def test_composite_child_override_loader_failure_is_safe(tmp_path):
"""override_loader 抛异常时, composite 应安全降级(用默认参数), 不崩溃。"""
custom_dir = tmp_path / "strategies" / "custom"
comp_dir = tmp_path / "strategies" / "composite"
custom_dir.mkdir(parents=True)
comp_dir.mkdir(parents=True)
(custom_dir / "thresh.py").write_text(_param_filter_code("thresh"), encoding="utf-8")
(comp_dir / "composite_blend.py").write_text(_composite_code("composite_blend", "thresh"), encoding="utf-8")
def bad_loader(sid: str) -> dict:
raise OSError("disk error")
engine = StrategyEngine(
strategy_dirs=[custom_dir, comp_dir],
override_loader=bad_loader,
)
ctx = StrategyDataContext(
asset_type="stock", timeframe="1d", as_of=date(2026, 1, 2),
current=_panel(),
)
# loader 抛异常应被捕获, 用默认参数跑(全选), 不报错
result = engine.run("composite_blend", ctx, overrides={"basic_filter": {"enabled": False}})
assert result.total > 0
+545
View File
@@ -0,0 +1,545 @@
"""叠加策略 (composite) 加载、引用校验与选股合并测试。
覆盖 CONTRIBUTING §9 矩阵中的「策略」与「回测」相关最低要求:
- 加载解析正确, source 推断为 composite
- 引用缺失 → 移除孤儿 composite, 不波及无辜策略(插件隔离)
- 禁止嵌套 composite、asset_types 不一致、超过上限均 fail-closed
- find_dependents 用于删除防护
- 选股 union / intersect 合并, 标准化排名加权融合 score
子策略用 polars_expr 后端(返回 True)以便用轻量 DataFrame 验证合并逻辑,
不依赖 matrix_native 的矩阵加载。回测矩阵路径在 M2 单独测试。
"""
from __future__ import annotations
from datetime import date
import numpy as np
import polars as pl
from app.strategy.engine import StrategyDataContext, StrategyEngine
def _filter_strategy_code(strategy_id: str, body: str = "return pl.lit(True)") -> str:
"""生成一个 polars_expr 策略文件(filter 始终命中全部标的)。"""
return f'''import polars as pl
META = {{
"id": "{strategy_id}",
"name": "{strategy_id}",
"asset_types": ["stock"],
"timeframes": ["1d"],
"scoring": {{"close": 1.0}},
}}
EXECUTION_BACKEND = "polars_expr"
def filter(df, params):
{body}
'''
def _composite_code(
strategy_id: str,
children: list[tuple[str, float]],
*,
name: str | None = None,
asset_types: list[str] | None = None,
merge_mode: str = "union",
min_confirm: int = 0,
) -> str:
"""生成一个声明式 composite 策略文件。"""
children_repr = ", ".join(
f'{{"strategy_id": "{cid}", "weight": {w}}}' for cid, w in children
)
ats = asset_types or ["stock"]
ats_repr = ", ".join(f'"{a}"' for a in ats)
params = (
f'{{"id": "merge_mode", "type": "select", '
f'"options": ["union", "intersect"], "default": "{merge_mode}"}}, '
f'{{"id": "min_confirm", "type": "int", "default": {min_confirm}}}'
)
return f'''META = {{
"id": "{strategy_id}",
"name": "{name or strategy_id}",
"asset_types": [{ats_repr}],
"timeframes": ["1d"],
"params": [{params}],
"children": [{children_repr}],
}}
EXECUTION_BACKEND = "composite"
'''
# ───────────────────────── 加载与引用校验 ─────────────────────────
def test_composite_loads_and_infers_source(tmp_path):
child_dir = tmp_path / "strategies" / "custom"
comp_dir = tmp_path / "strategies" / "composite"
child_dir.mkdir(parents=True)
comp_dir.mkdir(parents=True)
(child_dir / "child_a.py").write_text(_filter_strategy_code("child_a"), encoding="utf-8")
(comp_dir / "custom_blend.py").write_text(
_composite_code("custom_blend", [("child_a", 1.0)]), encoding="utf-8"
)
engine = StrategyEngine(strategy_dirs=[child_dir, comp_dir])
assert engine.has("custom_blend")
blend = engine.get("custom_blend")
assert blend.execution_backend == "composite"
assert blend.source == "composite"
assert blend.composite is not None
assert blend.composite.children[0].strategy_id == "child_a"
assert engine.load_errors() == []
def test_composite_missing_child_is_orphaned_without_blocking_others(tmp_path):
"""引用不存在的子策略 → composite 被移除并记错, 但不影响其他正常策略。"""
child_dir = tmp_path / "strategies" / "custom"
comp_dir = tmp_path / "strategies" / "composite"
child_dir.mkdir(parents=True)
comp_dir.mkdir(parents=True)
(child_dir / "real_child.py").write_text(_filter_strategy_code("real_child"), encoding="utf-8")
# composite 引用了不存在的 ghost_child
(comp_dir / "orphan_blend.py").write_text(
_composite_code("orphan_blend", [("ghost_child", 1.0)]), encoding="utf-8"
)
# 另一个正常 composite 不受影响
(comp_dir / "healthy_blend.py").write_text(
_composite_code("healthy_blend", [("real_child", 1.0)]), encoding="utf-8"
)
engine = StrategyEngine(strategy_dirs=[child_dir, comp_dir])
assert not engine.has("orphan_blend") # 孤儿被移除
assert engine.has("real_child") # 子策略不受影响
assert engine.has("healthy_blend") # 其他 composite 不受影响(隔离原则)
errors = engine.load_errors()
orphan_errors = [e for e in errors if "orphan_blend" in e["file"]]
assert len(orphan_errors) == 1
assert "ghost_child" in orphan_errors[0]["error"]
def test_nested_composite_rejected(tmp_path):
"""禁止 composite 嵌套 composite。"""
child_dir = tmp_path / "strategies" / "custom"
comp_dir = tmp_path / "strategies" / "composite"
child_dir.mkdir(parents=True)
comp_dir.mkdir(parents=True)
(child_dir / "leaf.py").write_text(_filter_strategy_code("leaf"), encoding="utf-8")
(comp_dir / "inner.py").write_text(
_composite_code("inner", [("leaf", 1.0)]), encoding="utf-8"
)
(comp_dir / "outer.py").write_text(
_composite_code("outer", [("inner", 1.0)]), encoding="utf-8"
)
engine = StrategyEngine(strategy_dirs=[child_dir, comp_dir])
assert engine.has("inner") # 单层 composite 合法
assert not engine.has("outer") # 嵌套被拒
outer_errors = [e for e in engine.load_errors() if "outer" in e["file"]]
assert len(outer_errors) == 1
assert "嵌套" in outer_errors[0]["error"] or "nested" in outer_errors[0]["error"].lower()
def test_composite_asset_type_mismatch_rejected(tmp_path):
"""composite 与子策略 asset_types 不一致 → fail-closed。"""
child_dir = tmp_path / "strategies" / "custom"
comp_dir = tmp_path / "strategies" / "composite"
child_dir.mkdir(parents=True)
comp_dir.mkdir(parents=True)
(child_dir / "etf_child.py").write_text(
_filter_strategy_code("etf_child"), encoding="utf-8"
)
# 子策略是 stock(默认), composite 声明 etf
(comp_dir / "mismatched.py").write_text(
_composite_code("mismatched", [("etf_child", 1.0)], asset_types=["etf"]),
encoding="utf-8",
)
engine = StrategyEngine(strategy_dirs=[child_dir, comp_dir])
assert not engine.has("mismatched")
errors = [e for e in engine.load_errors() if "mismatched" in e["file"]]
assert len(errors) == 1
assert "asset_types" in errors[0]["error"]
def test_composite_asset_type_subset_is_allowed(tmp_path):
"""子策略支持的范围 ⊇ composite 声明 → 合法(子集关系)。
内置策略常声明 ['stock','etf'], 用户叠加时只关注 stock, 不应被拒绝。
"""
child_dir = tmp_path / "strategies" / "custom"
comp_dir = tmp_path / "strategies" / "composite"
child_dir.mkdir(parents=True)
comp_dir.mkdir(parents=True)
# 子策略支持 stock + etf
multi_code = _filter_strategy_code("multi").replace(
'"asset_types": ["stock"]', '"asset_types": ["stock", "etf"]'
)
(child_dir / "multi.py").write_text(multi_code, encoding="utf-8")
# composite 只声明 stock(子集) → 合法
(comp_dir / "subset_ok.py").write_text(
_composite_code("subset_ok", [("multi", 1.0)], asset_types=["stock"]),
encoding="utf-8",
)
engine = StrategyEngine(strategy_dirs=[child_dir, comp_dir])
assert engine.has("subset_ok")
assert engine.load_errors() == []
def test_composite_exceeds_child_limit_rejected(tmp_path):
"""子策略数量超过 MAX_COMPOSITE_CHILDREN → fail-closed。"""
from app.strategy.engine import MAX_COMPOSITE_CHILDREN
child_dir = tmp_path / "strategies" / "custom"
comp_dir = tmp_path / "strategies" / "composite"
child_dir.mkdir(parents=True)
comp_dir.mkdir(parents=True)
for i in range(MAX_COMPOSITE_CHILDREN + 1):
(child_dir / f"c{i}.py").write_text(_filter_strategy_code(f"c{i}"), encoding="utf-8")
children = [(f"c{i}", 1.0) for i in range(MAX_COMPOSITE_CHILDREN + 1)]
(comp_dir / "too_many.py").write_text(
_composite_code("too_many", children), encoding="utf-8"
)
engine = StrategyEngine(strategy_dirs=[child_dir, comp_dir])
assert not engine.has("too_many")
errors = [e for e in engine.load_errors() if "too_many" in e["file"]]
assert len(errors) == 1
assert "limit" in errors[0]["error"] or "exceed" in errors[0]["error"]
def test_composite_with_filter_fn_rejected(tmp_path):
"""composite 策略声明了 filter 函数 → 加载失败。"""
comp_dir = tmp_path / "strategies" / "composite"
comp_dir.mkdir(parents=True)
code = _composite_code("bad", []) + "def filter(df, params):\n return pl.lit(True)\n"
(comp_dir / "bad.py").write_text(code, encoding="utf-8")
engine = StrategyEngine(strategy_dirs=[comp_dir])
assert not engine.has("bad")
assert any("bad" in e["file"] for e in engine.load_errors())
# ───────────────────────── find_dependents ─────────────────────────
def test_find_dependents_locates_referencing_composites(tmp_path):
child_dir = tmp_path / "strategies" / "custom"
comp_dir = tmp_path / "strategies" / "composite"
child_dir.mkdir(parents=True)
comp_dir.mkdir(parents=True)
(child_dir / "shared.py").write_text(_filter_strategy_code("shared"), encoding="utf-8")
(child_dir / "other.py").write_text(_filter_strategy_code("other"), encoding="utf-8")
(comp_dir / "blend_a.py").write_text(
_composite_code("blend_a", [("shared", 0.5), ("other", 0.5)]), encoding="utf-8"
)
(comp_dir / "blend_b.py").write_text(
_composite_code("blend_b", [("shared", 1.0)]), encoding="utf-8"
)
engine = StrategyEngine(strategy_dirs=[child_dir, comp_dir])
assert sorted(engine.find_dependents("shared")) == ["blend_a", "blend_b"]
assert engine.find_dependents("other") == ["blend_a"]
assert engine.find_dependents("nonexistent") == []
# ───────────────────────── 选股合并 ─────────────────────────
def _stock_panel(symbols: list[str], scores: list[float]) -> pl.DataFrame:
"""构造一个含 close 列(用于 scoring)的轻量 panel。"""
return pl.DataFrame({
"symbol": symbols,
"date": [date(2026, 1, 2)] * len(symbols),
"close": scores,
})
def test_composite_union_merge_combines_children(tmp_path):
"""union 模式: 两个子策略命中不同标的 → 合并后包含全部。"""
child_dir = tmp_path / "strategies" / "custom"
comp_dir = tmp_path / "strategies" / "composite"
child_dir.mkdir(parents=True)
comp_dir.mkdir(parents=True)
# child_a 只选 000001, child_b 只选 600000
(child_dir / "child_a.py").write_text(
_filter_strategy_code("child_a", body='return pl.col("symbol") == "000001.SZ"'),
encoding="utf-8",
)
(child_dir / "child_b.py").write_text(
_filter_strategy_code("child_b", body='return pl.col("symbol") == "600000.SH"'),
encoding="utf-8",
)
(comp_dir / "union_blend.py").write_text(
_composite_code("union_blend", [("child_a", 0.5), ("child_b", 0.5)]),
encoding="utf-8",
)
engine = StrategyEngine(strategy_dirs=[child_dir, comp_dir])
context = StrategyDataContext(
asset_type="stock",
timeframe="1d",
as_of=date(2026, 1, 2),
current=_stock_panel(["000001.SZ", "600000.SH"], [10.0, 20.0]),
)
# 禁用基础过滤(测试 panel 无 amount 等列); composite 会把 basic_filter 透传给子策略
result = engine.run(
"union_blend", context, overrides={"basic_filter": {"enabled": False}}
)
symbols = {row["symbol"] for row in result.rows}
assert symbols == {"000001.SZ", "600000.SH"}
assert result.total == 2
# 合并 score 应该来自排名归一加权(两个子各命中一个, 均为各自第一 → norm=1)
assert all(0 < result.scores[s] <= 100 for s in result.scores)
def test_composite_intersect_requires_min_confirm(tmp_path):
"""intersect 模式: 只有多个子策略共同命中才入选。"""
child_dir = tmp_path / "strategies" / "custom"
comp_dir = tmp_path / "strategies" / "composite"
child_dir.mkdir(parents=True)
comp_dir.mkdir(parents=True)
# 两个子策略都选 000001(共振), 但 child_b 还选 600000(非共振)
(child_dir / "child_a.py").write_text(
_filter_strategy_code("child_a", body='return pl.col("symbol") == "000001.SZ"'),
encoding="utf-8",
)
(child_dir / "child_b.py").write_text(
_filter_strategy_code(
"child_b",
body='return pl.col("symbol").is_in(["000001.SZ", "600000.SH"])',
),
encoding="utf-8",
)
(comp_dir / "intersect_blend.py").write_text(
_composite_code(
"intersect_blend",
[("child_a", 0.5), ("child_b", 0.5)],
merge_mode="intersect",
min_confirm=2,
),
encoding="utf-8",
)
engine = StrategyEngine(strategy_dirs=[child_dir, comp_dir])
context = StrategyDataContext(
asset_type="stock",
timeframe="1d",
as_of=date(2026, 1, 2),
current=_stock_panel(["000001.SZ", "600000.SH"], [10.0, 20.0]),
)
result = engine.run(
"intersect_blend", context, overrides={"basic_filter": {"enabled": False}}
)
# 只有 000001 同时被两个子策略命中
symbols = {row["symbol"] for row in result.rows}
assert symbols == {"000001.SZ"}
def test_composite_weighted_score_ranking(tmp_path):
"""权重影响最终排名: 高权重子策略的最优标的应排前。"""
from app.strategy import composite as composite_mod
from app.strategy.engine import StrategyResult
# 直接测合并器: 两个子策略, 命中相同标的但内部排名不同。
as_of = date(2026, 1, 2)
res_a = StrategyResult(
as_of=as_of,
strategy_id="a",
scores={"X": 100.0, "Y": 50.0}, # X 优于 Y
)
res_b = StrategyResult(
as_of=as_of,
strategy_id="b",
scores={"X": 10.0, "Y": 90.0}, # Y 优于 X
)
# b 权重远大于 a → 合并后 Y 应得分更高
merged = composite_mod.merge_results(
[res_a, res_b],
[0.1, 0.9],
"union",
0,
as_of=as_of,
strategy_id="blend",
)
# a 中 X rank=1(norm=1), Y rank=2(norm=0)
# b 中 X rank=2(norm=0), Y rank=1(norm=1)
# X = (0.1*1 + 0.9*0)/1.0 = 0.1; Y = (0.1*0 + 0.9*1)/1.0 = 0.9
assert merged.scores["Y"] > merged.scores["X"]
def test_composite_no_scores_uses_neutral(tmp_path):
"""子策略无 score 时用中性分, 不报错也不污染。"""
from app.strategy import composite as composite_mod
from app.strategy.engine import StrategyResult
as_of = date(2026, 1, 2)
res = StrategyResult(
as_of=as_of,
strategy_id="a",
rows=[{"symbol": "X"}, {"symbol": "Y"}],
scores={}, # 无 score
)
merged = composite_mod.merge_results([res], [1.0], "union", 0, as_of=as_of, strategy_id="b")
assert set(merged.scores) == {"X", "Y"}
assert all(abs(s - 50.0) < 0.01 for s in merged.scores.values()) # 中性分 0.5*100
def test_composite_empty_children_returns_empty(tmp_path):
"""空子结果列表 → 返回空 StrategyResult。"""
from app.strategy import composite as composite_mod
as_of = date(2026, 1, 2)
merged = composite_mod.merge_results(
[], [], "union", 0, as_of=as_of, strategy_id="empty"
)
assert merged.total == 0
assert merged.scores == {}
# ───────────────────────── 回测合并: merge_signal_matrices ─────────────────────────
def _make_sig(shape, *, entry, exit_, score=None):
"""构造一个轻量 SignalMatrix(用 make_signal_matrix 保证 dtype/只读)。"""
from app.backtest.matrix import make_signal_matrix
entry_arr = np.array(entry, dtype=np.uint8)
exit_arr = np.array(exit_, dtype=np.uint8)
score_arr = (
np.array(score, dtype=np.float32)
if score is not None
else np.full(shape, 50.0, dtype=np.float32)
)
return make_signal_matrix(
shape,
entry=entry_arr,
exit=exit_arr,
score=score_arr,
)
def test_merge_signal_matrices_union_entry():
"""union 模式: entry = OR(各子 entry)。"""
from app.strategy import composite as composite_mod
shape = (3, 2)
# child A 选中 asset 0; child B 选中 asset 1
sig_a = _make_sig(shape, entry=[[1, 0], [0, 0], [0, 0]], exit_=[[0, 0], [0, 0], [0, 0]])
sig_b = _make_sig(shape, entry=[[0, 1], [0, 0], [0, 0]], exit_=[[0, 0], [0, 0], [0, 0]])
merged = composite_mod.merge_signal_matrices(
shape, [sig_a, sig_b], [("a", 0.5), ("b", 0.5)], "union", 0, max_hold=2
)
assert merged.entry[0, 0] == 1 # A 选中
assert merged.entry[0, 1] == 1 # B 选中
assert merged.entry[1].sum() == 0 # 后续无新入场
def test_merge_signal_matrices_intersect_entry():
"""intersect 模式: 只有多个子策略同时命中才入选。"""
from app.strategy import composite as composite_mod
shape = (2, 2)
# asset 0 被两个子策略同时命中(共振); asset 1 只被 A 命中
sig_a = _make_sig(shape, entry=[[1, 1], [0, 0]], exit_=[[0, 0], [0, 0]])
sig_b = _make_sig(shape, entry=[[1, 0], [0, 0]], exit_=[[0, 0], [0, 0]])
merged = composite_mod.merge_signal_matrices(
shape, [sig_a, sig_b], [("a", 0.5), ("b", 0.5)], "intersect", 2, max_hold=2
)
assert merged.entry[0, 0] == 1 # 共振入选
assert merged.entry[0, 1] == 0 # 非共振排除
def test_merge_exit_projection_prevents_cross_close():
"""退出投影: 子策略 B 的 exit 不会平掉子策略 A 选中的仓位。
场景: A 在 t=0 买入 asset 0, max_hold=3 → A 的持仓窗口 t∈[0,2]。
B 没买 asset 0, 但 B 在 t=1 对 asset 0 标了 exit(模拟无关退出信号)。
期望: 合并 exit 在 t=1 的 asset 0 应为 0(B 未持仓, 其 exit 被投影清零)。
"""
from app.strategy import composite as composite_mod
shape = (3, 1)
sig_a = _make_sig(shape, entry=[[1], [0], [0]], exit_=[[0], [0], [0]])
# B 没买 asset 0, 但在 t=1 标了 exit
sig_b = _make_sig(shape, entry=[[0], [0], [0]], exit_=[[0], [1], [0]])
merged = composite_mod.merge_signal_matrices(
shape, [sig_a, sig_b], [("a", 1.0), ("b", 1.0)], "union", 0, max_hold=3
)
# 关键断言: B 的 exit(t=1) 被投影清零, 因为 B 在 asset 0 没有持仓窗口
assert merged.exit[1, 0] == 0, "B 的退出信号不应平掉 A 的仓位"
def test_merge_exit_respects_child_own_exit():
"""退出投影: 子策略自己的 exit 在自己持仓窗口内有效。
场景: A 在 t=0 买入, 在 t=2 标 exit; max_hold=3。
期望: 合并 exit 在 t=2 为 1(A 自己的退出在其持仓窗口内, 生效)。
"""
from app.strategy import composite as composite_mod
shape = (4, 1)
sig_a = _make_sig(shape, entry=[[1], [0], [0], [0]], exit_=[[0], [0], [1], [0]])
merged = composite_mod.merge_signal_matrices(
shape, [sig_a], [("a", 1.0)], "union", 0, max_hold=3
)
assert merged.exit[2, 0] == 1 # A 自己的 exit 在窗口内, 生效
def test_merge_exit_max_hold_caps_window():
"""退出投影: 持仓窗口由 max_hold 封顶; 超出窗口后 exit 不生效。
场景: A 在 t=0 买入, max_hold=2 → 窗口 t∈[0,1]。A 在 t=3 标 exit(超出窗口)。
期望: 合并 exit 在 t=3 为 0(超出持仓窗口, exit 无效)。
"""
from app.strategy import composite as composite_mod
shape = (5, 1)
sig_a = _make_sig(shape, entry=[[1], [0], [0], [0], [0]], exit_=[[0], [0], [0], [1], [0]])
merged = composite_mod.merge_signal_matrices(
shape, [sig_a], [("a", 1.0)], "union", 0, max_hold=2
)
assert merged.exit[1, 0] == 0 # 窗口内 A 无 exit
assert merged.exit[3, 0] == 0 # 超出窗口, exit 无效
def test_merge_entry_signal_code_records_source():
"""合并后 entry_signal_code 标记来源子策略(归因用)。"""
from app.strategy import composite as composite_mod
shape = (1, 2)
sig_a = _make_sig(shape, entry=[[1, 0]], exit_=[[0, 0]])
sig_b = _make_sig(shape, entry=[[0, 1]], exit_=[[0, 0]])
merged = composite_mod.merge_signal_matrices(
shape, [sig_a, sig_b], [("child_a", 1.0), ("child_b", 1.0)], "union", 0, max_hold=1
)
# asset 0 来自 child A (code=0), asset 1 来自 child B (code=1)
assert merged.entry_signal_code[0, 0] == 0
assert merged.entry_signal_code[0, 1] == 1
assert merged.entry_signal_ids == ("composite:child_a", "composite:child_b")
@@ -0,0 +1,230 @@
"""叠加策略 API 与删除防护测试 (M2)。
覆盖:
- _render_composite_code 渲染声明式 .py 正确
- _save_composite_strategy 创建/更新/校验(前缀/嵌套/子策略不存在/重复创建)
- _strategy_detail 回显 composite_children
- delete_strategy 删除被引用子策略时 409 fail-closed
复用 test_strategy_code_save.py 的 SimpleNamespace 构造模式。
"""
from __future__ import annotations
from types import SimpleNamespace
import pytest
from fastapi import HTTPException
from app.api.strategy import (
CompositeChildItem,
StrategyCompositeSaveRequest,
_render_composite_code,
_save_composite_strategy,
_strategy_detail,
)
from app.strategy.engine import StrategyEngine
def _filter_code(strategy_id: str) -> str:
meta = (
f'{{"id": "{strategy_id}", "name": "{strategy_id}", '
'"asset_types": ["stock"], "timeframes": ["1d"]}'
)
return f'''import polars as pl
META = {meta}
EXECUTION_BACKEND = "polars_expr"
def filter(df, params):
return pl.lit(True)
'''
def _setup_engine(tmp_path):
"""构造含 custom + composite 目录的 engine 和假 request。"""
custom_dir = tmp_path / "strategies" / "custom"
comp_dir = tmp_path / "strategies" / "composite"
custom_dir.mkdir(parents=True)
comp_dir.mkdir(parents=True)
engine = StrategyEngine(strategy_dirs=[custom_dir, comp_dir])
repo = SimpleNamespace(store=SimpleNamespace(data_dir=tmp_path))
request = SimpleNamespace(
app=SimpleNamespace(state=SimpleNamespace(repo=repo, strategy_engine=engine))
)
return engine, request, custom_dir, comp_dir
def _composite_req(sid, children, **kw):
return StrategyCompositeSaveRequest(
strategy_id=sid,
name=kw.get("name", sid),
description=kw.get("description", ""),
children=[CompositeChildItem(strategy_id=c, weight=w) for c, w in children],
merge_mode=kw.get("merge_mode", "union"),
min_confirm=kw.get("min_confirm", 0),
mode=kw.get("mode", "create"),
)
# ───────────────────────── 渲染 ─────────────────────────
def test_render_composite_code_contains_children_and_backend():
code = _render_composite_code(
"composite_blend", "我的叠加", "描述",
[{"strategy_id": "child_a", "weight": 0.4}, {"strategy_id": "child_b", "weight": 0.6}],
"union", 0,
)
assert 'EXECUTION_BACKEND = "composite"' in code
assert "composite_blend" in code
assert "child_a" in code
assert "child_b" in code
assert "0.4" in code
# ───────────────────────── 保存 ─────────────────────────
def test_save_composite_creates_and_loads(tmp_path):
engine, request, custom_dir, _ = _setup_engine(tmp_path)
(custom_dir / "child_a.py").write_text(_filter_code("child_a"), encoding="utf-8")
engine.reload()
result = _save_composite_strategy(
_composite_req("composite_blend", [("child_a", 1.0)]), request
)
assert result["ok"] is True
assert result["source"] == "composite"
assert engine.has("composite_blend")
blend = engine.get("composite_blend")
assert blend.execution_backend == "composite"
assert blend.composite.children[0].strategy_id == "child_a"
def test_save_composite_rejects_missing_composite_prefix(tmp_path):
engine, request, custom_dir, _ = _setup_engine(tmp_path)
(custom_dir / "child_a.py").write_text(_filter_code("child_a"), encoding="utf-8")
engine.reload()
with pytest.raises(ValueError, match="composite_"):
_save_composite_strategy(
_composite_req("bad_prefix", [("child_a", 1.0)]), request
)
def test_save_composite_rejects_missing_child(tmp_path):
_engine, request, _, _ = _setup_engine(tmp_path)
with pytest.raises(ValueError, match="不存在"):
_save_composite_strategy(
_composite_req("composite_blend", [("ghost", 1.0)]), request
)
def test_save_composite_rejects_nested_composite(tmp_path):
engine, request, custom_dir, _comp_dir = _setup_engine(tmp_path)
(custom_dir / "leaf.py").write_text(_filter_code("leaf"), encoding="utf-8")
engine.reload()
# 先创建一个合法 composite
_save_composite_strategy(
_composite_req("composite_inner", [("leaf", 1.0)]), request
)
with pytest.raises(ValueError, match="嵌套"):
_save_composite_strategy(
_composite_req("composite_outer", [("composite_inner", 1.0)]), request
)
def test_save_composite_update_mode(tmp_path):
engine, request, custom_dir, _ = _setup_engine(tmp_path)
(custom_dir / "child_a.py").write_text(_filter_code("child_a"), encoding="utf-8")
(custom_dir / "child_b.py").write_text(_filter_code("child_b"), encoding="utf-8")
engine.reload()
_save_composite_strategy(
_composite_req("composite_blend", [("child_a", 1.0)]), request
)
# 更新: 改子策略
result = _save_composite_strategy(
_composite_req("composite_blend", [("child_a", 0.5), ("child_b", 0.5)], mode="update"),
request,
)
assert result["ok"]
blend = engine.get("composite_blend")
assert {c.strategy_id for c in blend.composite.children} == {"child_a", "child_b"}
def test_save_composite_update_rejects_non_composite(tmp_path):
engine, request, custom_dir, _ = _setup_engine(tmp_path)
# 一个普通(polars_expr)策略, 用 composite_ 前缀以通过 id 校验进入后续检查
(custom_dir / "composite_plain.py").write_text(_filter_code("composite_plain"), encoding="utf-8")
engine.reload()
with pytest.raises(ValueError, match="不是叠加策略"):
_save_composite_strategy(
_composite_req("composite_plain", [], mode="update"), request
)
# ───────────────────────── _strategy_detail ─────────────────────────
def test_strategy_detail_shows_composite_children(tmp_path):
engine, request, custom_dir, _ = _setup_engine(tmp_path)
(custom_dir / "child_a.py").write_text(_filter_code("child_a"), encoding="utf-8")
(custom_dir / "child_b.py").write_text(_filter_code("child_b"), encoding="utf-8")
engine.reload()
_save_composite_strategy(
_composite_req("composite_blend", [("child_a", 0.4), ("child_b", 0.6)]), request
)
detail = _strategy_detail(engine.get("composite_blend"))
assert detail["execution_backend"] == "composite"
assert detail["composite_children"] is not None
children_map = {c["id"]: c["weight"] for c in detail["composite_children"]}
assert children_map == {"child_a": 0.4, "child_b": 0.6}
def test_strategy_detail_non_composite_has_null_children(tmp_path):
engine, _request, custom_dir, _ = _setup_engine(tmp_path)
(custom_dir / "child_a.py").write_text(_filter_code("child_a"), encoding="utf-8")
engine.reload()
detail = _strategy_detail(engine.get("child_a"))
assert detail["composite_children"] is None
# ───────────────────────── 删除防护 ─────────────────────────
def test_delete_referenced_child_blocked(tmp_path):
"""删除被 composite 引用的子策略 → 409 fail-closed。"""
engine, request, custom_dir, _ = _setup_engine(tmp_path)
(custom_dir / "child_a.py").write_text(_filter_code("child_a"), encoding="utf-8")
engine.reload()
_save_composite_strategy(
_composite_req("composite_blend", [("child_a", 1.0)]), request
)
# 直接调端点函数(delete_strategy 需要 request)
from app.api.strategy import delete_strategy
with pytest.raises(HTTPException) as exc_info:
delete_strategy("child_a", request)
assert exc_info.value.status_code == 409
assert "composite_blend" in exc_info.value.detail
def test_delete_composite_itself_succeeds(tmp_path):
"""删除 composite 本身(不被引用)应成功。"""
engine, request, custom_dir, _ = _setup_engine(tmp_path)
(custom_dir / "child_a.py").write_text(_filter_code("child_a"), encoding="utf-8")
engine.reload()
_save_composite_strategy(
_composite_req("composite_blend", [("child_a", 1.0)]), request
)
from app.api.strategy import delete_strategy
result = delete_strategy("composite_blend", request)
assert result["ok"] is True
assert not engine.has("composite_blend")
@@ -36,6 +36,7 @@ const STRATEGY_SOURCE_META = {
builtin: { label: '内置', className: 'border-accent/25 bg-accent/10 text-accent' },
custom: { label: '自定义', className: 'border-emerald-400/25 bg-emerald-400/10 text-emerald-400' },
ai: { label: 'AI', className: 'border-amber-400/25 bg-amber-400/10 text-amber-400' },
composite: { label: '叠加', className: 'border-teal-500/25 bg-teal-500/10 text-teal-400' },
} as const
const emptyRule = (preset?: Partial<MonitorRule>): MonitorRule => ({
@@ -94,7 +95,7 @@ export function RuleEditor({ rule, preset, simple, onClose, onSaved }: Props) {
const [error, setError] = useState('')
const [symbolQuery, setSymbolQuery] = useState('')
const [strategyQuery, setStrategyQuery] = useState('')
const [strategyCategory, setStrategyCategory] = useState<'all' | 'builtin' | 'custom' | 'ai'>('all')
const [strategyCategory, setStrategyCategory] = useState<'all' | 'builtin' | 'custom' | 'ai' | 'composite'>('all')
// 标的搜索资产类型: ETF 一并搜股票; 指数只搜指数; 否则只搜股票。
const symbolAssetTypes = assetType === 'etf' ? 'stock,etf' : assetType === 'index' ? 'index' : 'stock'
const symbolSearch = useQuery({
@@ -212,6 +213,7 @@ export function RuleEditor({ rule, preset, simple, onClose, onSaved }: Props) {
{ key: 'builtin' as const, label: '内置', count: strategyPresets.filter(strategy => strategy.source === 'builtin').length },
{ key: 'custom' as const, label: '自定义', count: strategyPresets.filter(strategy => strategy.source === 'custom').length },
{ key: 'ai' as const, label: 'AI', count: strategyPresets.filter(strategy => strategy.source === 'ai').length },
{ key: 'composite' as const, label: '叠加', count: strategyPresets.filter(strategy => strategy.source === 'composite').length },
]
const onSignalPickerChange = (next: string[]) => {
@@ -0,0 +1,351 @@
import { useState, useMemo, useEffect, useRef } from 'react'
import { motion, AnimatePresence } from 'framer-motion'
import { X, Layers, Plus, Loader2, Search } from 'lucide-react'
import { api, type ScreenerStrategy } from '@/lib/api'
interface Props {
open: boolean
onClose: () => void
onSavedId?: (id: string) => void | Promise<void>
/** 编辑模式: 传入已有 composite 策略 id 时为 update, 否则 create */
editStrategyId?: string | null
}
interface ChildItem {
strategy_id: string
weight: number
}
const SRC_MAP: Record<string, string> = { builtin: '内置', custom: '自定义', ai: 'AI' }
const BADGE_CLS: Record<string, string> = {
builtin: 'bg-accent/10 text-accent border-accent/20',
ai: 'bg-purple-500/10 text-purple-400 border-purple-500/20',
custom: 'bg-amber-400/10 text-amber-400 border-amber-400/30',
}
export function CompositeStrategyDialog({ open, onClose, onSavedId, editStrategyId }: Props) {
const isEdit = !!editStrategyId
const [name, setName] = useState('')
const [description, setDescription] = useState('')
const [strategyId, setStrategyId] = useState('')
const [children, setChildren] = useState<ChildItem[]>([])
const [mergeMode, setMergeMode] = useState<'union' | 'intersect'>('union')
const [minConfirm, setMinConfirm] = useState(0)
const [saving, setSaving] = useState(false)
const [error, setError] = useState('')
const [search, setSearch] = useState('')
// 记录鼠标按下时是否落在遮罩上, 避免拖选文本时鼠标移出面板导致误关。
const mouseDownOnBackdrop = useRef(false)
// 拉取所有可用子策略(排除 composite 自身)
const [available, setAvailable] = useState<ScreenerStrategy[]>([])
const [loadingList, setLoadingList] = useState(false)
useEffect(() => {
if (!open) return
// 重置表单
setName('')
setDescription('')
// 创建模式自动生成 ID(composite_ + 时间戳), 编辑模式用现有 ID
setStrategyId(isEdit ? (editStrategyId ?? '') : `composite_${Date.now().toString(36)}`)
setChildren([])
setMergeMode('union')
setMinConfirm(0)
setError('')
setSearch('')
setLoadingList(true)
api.screenerStrategies()
.then(data => {
// 排除 composite 策略(不能嵌套)
setAvailable((data.presets ?? []).filter(s => s.source !== 'composite'))
})
.catch(() => setAvailable([]))
.finally(() => setLoadingList(false))
// 编辑模式: 加载现有配置回显
if (isEdit && editStrategyId) {
api.strategyGet(editStrategyId)
.then(detail => {
setName(detail.name)
setDescription(detail.description)
setMergeMode((detail.params_defaults?.merge_mode as 'union' | 'intersect') ?? 'union')
setMinConfirm(detail.params_defaults?.min_confirm ?? 0)
if (detail.composite_children) {
setChildren(detail.composite_children.map(c => ({ strategy_id: c.id, weight: c.weight })))
}
})
.catch(() => {})
}
}, [open, isEdit, editStrategyId])
const filteredAvailable = useMemo(() => {
const keyword = search.trim().toLowerCase()
const selectedIds = new Set(children.map(c => c.strategy_id))
return available.filter(s => {
if (selectedIds.has(s.id)) return false
if (!keyword) return true
return s.name.toLowerCase().includes(keyword) || s.id.toLowerCase().includes(keyword)
})
}, [available, children, search])
const addChild = (s: ScreenerStrategy) => {
setChildren(prev => [...prev, { strategy_id: s.id, weight: 1.0 }])
}
const removeChild = (id: string) => {
setChildren(prev => prev.filter(c => c.strategy_id !== id))
}
const updateWeight = (id: string, weight: number) => {
setChildren(prev => prev.map(c => c.strategy_id === id ? { ...c, weight } : c))
}
const totalWeight = useMemo(
() => children.reduce((sum, c) => sum + (c.weight || 0), 0),
[children],
)
const normalizeWeights = () => {
if (totalWeight <= 0) return
setChildren(prev => prev.map(c => ({ ...c, weight: Math.round((c.weight / totalWeight) * 1000) / 1000 })))
}
const handleSave = async () => {
setError('')
if (!name.trim()) {
setError('请输入策略名称')
return
}
if (children.length === 0) {
setError('请至少选择一个子策略')
return
}
setSaving(true)
try {
const result = await api.strategySaveComposite({
strategy_id: isEdit ? (editStrategyId ?? '') : strategyId.trim(),
name: name.trim(),
description: description.trim(),
children: children.map(c => ({ strategy_id: c.strategy_id, weight: c.weight })),
merge_mode: mergeMode,
min_confirm: minConfirm,
mode: isEdit ? 'update' : 'create',
})
await onSavedId?.(result.strategy_id)
setTimeout(() => onClose(), 800)
} catch (e: any) {
setError(String(e?.message ?? '保存失败'))
}
setSaving(false)
}
return (
<AnimatePresence>
{open && (
<motion.div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
onMouseDown={(e) => {
mouseDownOnBackdrop.current = e.target === e.currentTarget
}}
onClick={(e) => {
// 仅当按下和松开都在遮罩上才关闭, 避免面板内拖选文本误关。
if (mouseDownOnBackdrop.current && e.target === e.currentTarget) onClose()
}}
>
<motion.div
className="relative flex max-h-[88vh] w-full max-w-3xl flex-col overflow-hidden rounded-panel border border-border bg-base shadow-2xl"
initial={{ scale: 0.96, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
exit={{ scale: 0.96, opacity: 0 }}
onClick={e => e.stopPropagation()}
>
{/* 头部 */}
<div className="flex items-center gap-2 border-b border-border px-4 py-3">
<Layers className="h-4 w-4 text-teal-400" />
<span className="text-sm font-semibold text-foreground">
{isEdit ? '编辑叠加策略' : '创建叠加策略'}
</span>
<span className="text-[10px] text-muted/60">
,
</span>
<button onClick={onClose} className="ml-auto text-muted hover:text-foreground">
<X className="h-4 w-4" />
</button>
</div>
{/* 主体 */}
<div className="flex-1 overflow-y-auto p-4 space-y-4">
{/* 基本信息 */}
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<div>
<label className="mb-1 block text-xs text-muted"> ID</label>
<input
value={strategyId}
readOnly
disabled
placeholder="composite_..."
className="w-full rounded-btn border border-border bg-elevated px-2 py-1.5 font-mono text-[11px] text-muted cursor-not-allowed"
/>
</div>
<div>
<label className="mb-1 block text-xs text-muted"></label>
<input
value={name}
onChange={e => setName(e.target.value)}
placeholder="我的叠加策略"
className="w-full rounded-btn border border-border bg-elevated px-2 py-1.5 text-xs text-foreground placeholder:text-muted/40"
/>
</div>
</div>
<div>
<label className="mb-1 block text-xs text-muted"></label>
<input
value={description}
onChange={e => setDescription(e.target.value)}
placeholder="说明这个叠加策略的意图"
className="w-full rounded-btn border border-border bg-elevated px-2 py-1.5 text-xs text-foreground placeholder:text-muted/40"
/>
</div>
{/* 合并模式 */}
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<div>
<label className="mb-1 block text-xs text-muted"></label>
<select
value={mergeMode}
onChange={e => setMergeMode(e.target.value as 'union' | 'intersect')}
className="w-full rounded-btn border border-border bg-elevated px-2 py-1.5 text-xs text-foreground"
>
<option value="union"></option>
<option value="intersect"> N </option>
</select>
</div>
<div>
<label className="mb-1 block text-xs text-muted">0=</label>
<input
type="number"
min={0}
value={minConfirm}
onChange={e => setMinConfirm(Math.max(0, parseInt(e.target.value) || 0))}
disabled={mergeMode !== 'intersect'}
className="w-full rounded-btn border border-border bg-elevated px-2 py-1.5 text-xs text-foreground disabled:opacity-50"
/>
</div>
</div>
{/* 已选子策略 */}
<div>
<div className="mb-1.5 flex items-center justify-between">
<label className="text-xs font-medium text-foreground">
{children.length}
</label>
<span className="text-[10px] text-muted flex items-center gap-1.5">
: {totalWeight.toFixed(2)}
{totalWeight > 0 && Math.abs(totalWeight - 1) > 0.001 && (
<button onClick={normalizeWeights} className="text-teal-400 hover:text-teal-300 underline underline-offset-2"></button>
)}
</span>
</div>
<div className="space-y-1.5">
{children.length === 0 && (
<div className="rounded-btn border border-dashed border-border px-3 py-4 text-center text-xs text-muted">
</div>
)}
{children.map(c => {
const s = available.find(a => a.id === c.strategy_id)
return (
<div key={c.strategy_id} className="flex items-center gap-2 rounded-btn border border-border bg-elevated px-2 py-1.5">
<span className="flex-1 truncate text-xs text-foreground">{s?.name ?? c.strategy_id}</span>
{s?.source && (
<span className={`rounded border px-1 text-[8px] ${BADGE_CLS[s.source] ?? ''}`}>
{SRC_MAP[s.source] ?? s.source}
</span>
)}
<input
type="number"
step={0.05}
min={0}
value={c.weight}
onChange={e => updateWeight(c.strategy_id, parseFloat(e.target.value) || 0)}
className="w-16 rounded border border-border bg-base px-1.5 py-0.5 text-[11px] text-foreground"
/>
<button onClick={() => removeChild(c.strategy_id)} className="text-danger/60 hover:text-danger">
<X className="h-3 w-3" />
</button>
</div>
)
})}
</div>
</div>
{/* 可选子策略 */}
<div>
<label className="mb-1.5 block text-xs font-medium text-foreground"></label>
<div className="relative mb-1.5">
<Search className="absolute left-2 top-1/2 h-3 w-3 -translate-y-1/2 text-muted/50" />
<input
value={search}
onChange={e => setSearch(e.target.value)}
placeholder="搜索策略名称或 ID"
className="w-full rounded-btn border border-border bg-elevated py-1.5 pl-7 pr-2 text-xs text-foreground placeholder:text-muted/40"
/>
</div>
<div className="max-h-48 space-y-1 overflow-y-auto rounded-btn border border-border bg-elevated p-1.5">
{loadingList && (
<div className="flex items-center justify-center gap-1.5 py-3 text-xs text-muted">
<Loader2 className="h-3 w-3 animate-spin" />
</div>
)}
{!loadingList && filteredAvailable.length === 0 && (
<div className="py-3 text-center text-xs text-muted"></div>
)}
{filteredAvailable.map(s => (
<button
key={s.id}
onClick={() => addChild(s)}
className="flex w-full items-center gap-1.5 rounded px-2 py-1 text-left text-xs text-foreground hover:bg-accent/10"
>
<Plus className="h-3 w-3 shrink-0 text-teal-400" />
<span className="flex-1 truncate">{s.name}</span>
{s.source && (
<span className={`rounded border px-1 text-[8px] ${BADGE_CLS[s.source] ?? ''}`}>
{SRC_MAP[s.source] ?? s.source}
</span>
)}
<span className="font-mono text-[9px] text-muted/50">{s.id}</span>
</button>
))}
</div>
</div>
{error && (
<div className="rounded-btn border border-danger/30 bg-danger/10 px-3 py-2 text-xs text-danger">
{error}
</div>
)}
</div>
{/* 底部 */}
<div className="flex items-center justify-end gap-2 border-t border-border px-4 py-3">
<button
onClick={onClose}
className="rounded-btn border border-border px-3 py-1.5 text-xs text-muted hover:text-foreground"
>
</button>
<button
onClick={handleSave}
disabled={saving}
className="inline-flex items-center gap-1.5 rounded-btn bg-teal-500/15 px-3 py-1.5 text-xs font-medium text-teal-400 border border-teal-500/30 hover:bg-teal-500/25 disabled:opacity-50"
>
{saving && <Loader2 className="h-3 w-3 animate-spin" />}
{isEdit ? '保存修改' : '创建'}
</button>
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
)
}
@@ -63,11 +63,12 @@ export function cardWrapCls(size: CardSize): string {
// ===== 来源标签 =====
const SRC_MAP: Record<string, string> = { builtin: '内置', custom: '自定义', ai: 'AI' }
const SRC_MAP: Record<string, string> = { builtin: '内置', custom: '自定义', ai: 'AI', composite: '叠加' }
const BADGE_CLS_MAP: Record<string, string> = {
builtin: 'bg-secondary/10 text-muted border-border',
ai: 'bg-purple-500/10 text-purple-400 border-purple-500/30',
custom: 'bg-amber-400/10 text-amber-400 border-amber-400/30',
composite: 'bg-teal-500/10 text-teal-400 border-teal-500/30',
}
// ===== 策略卡片 =====
@@ -2,6 +2,7 @@ import { useState, useMemo, useEffect, useCallback, useRef } from 'react'
import { motion, AnimatePresence, Reorder } from 'framer-motion'
import { X, Plus, GripVertical, Upload, Loader2 } from 'lucide-react'
import { api, type StrategyDetail } from '@/lib/api'
import { useDialogBackdrop } from '@/lib/useDialogBackdrop'
interface Props {
pool: string[]
@@ -42,6 +43,7 @@ function fileStem(name: string): string {
}
export function StrategyPoolDialog({ pool, onConfirm, onClose }: Props) {
const backdrop = useDialogBackdrop(onClose)
// 草稿状态: 打开时从 pool 复制, 操作只改草稿, 点确定才提交
const [draftPool, setDraftPool] = useState<string[]>(() => [...pool])
const [allStrategies, setAllStrategies] = useState<StrategyDetail[]>([])
@@ -121,7 +123,7 @@ export function StrategyPoolDialog({ pool, onConfirm, onClose }: Props) {
mode: 'create',
})
await loadStrategies()
setActiveTab(result.source)
setActiveTab(result.source === 'ai' ? 'ai' : 'custom')
setImportMsg(`已导入到${result.source === 'ai' ? 'AI' : '自定义'}策略: ${result.strategy_id}`)
} catch (e: any) {
setImportError(String(e?.message ?? '导入失败'))
@@ -138,7 +140,7 @@ export function StrategyPoolDialog({ pool, onConfirm, onClose }: Props) {
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50"
onClick={e => { if (e.target === e.currentTarget) onClose() }}
{...backdrop}
>
<motion.div
initial={{ opacity: 0, scale: 0.95, y: 10 }}
@@ -1,7 +1,7 @@
import { useState, useEffect, useCallback } from 'react'
import { motion, AnimatePresence } from 'framer-motion'
import { X, Settings2, RotateCcw, Save, ChevronDown, Filter, Star, TrendingUp, Sparkles, Download } from 'lucide-react'
import { api, type StrategyDetail, type StrategyParamDef } from '@/lib/api'
import { X, Settings2, RotateCcw, Save, ChevronDown, Filter, Star, TrendingUp, Sparkles, Download, Layers, Plus, Trash2 } from 'lucide-react'
import { api, type StrategyDetail, type StrategyParamDef, type CompositeChildInfo } from '@/lib/api'
import { BUILTIN_COLUMNS } from '@/lib/watchlist-columns'
import { color } from '@/lib/colors'
import { SignalPicker } from './SignalPicker'
@@ -216,6 +216,11 @@ export function StrategySettingsDialog({ strategyId, onClose, onSaved, onAiModif
const [exitSignals, setExitSignals] = useState<string[]>([])
const [displayLimit, setDisplayLimit] = useState<number | null>(null)
const [basicFilterEnabled, setBasicFilterEnabled] = useState(true)
// 叠加策略: 子策略列表与权重(composite 专属, 编辑权重后随 override 保存)
const [compositeChildren, setCompositeChildren] = useState<CompositeChildInfo[]>([])
// 可选子策略列表 + 添加面板开关(composite 设置用)
const [allStrategies, setAllStrategies] = useState<{ id: string; name: string; source?: string }[]>([])
const [showAddChild, setShowAddChild] = useState(false)
const [editingScoring, setEditingScoring] = useState(false)
const [deleting, setDeleting] = useState(false)
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
@@ -247,11 +252,32 @@ export function StrategySettingsDialog({ strategyId, onClose, onSaved, onAiModif
setExitSignals(d.exit_signals ?? [])
setDisplayLimit(d.display_limit ?? null)
setBasicFilterEnabled(d.basic_filter?.enabled !== false)
setCompositeChildren(d.composite_children ?? [])
// composite 策略: 加载全部可选子策略(排除自身和其他 composite)供添加
if (d.source === 'composite') {
api.screenerStrategies().then(data => {
setAllStrategies((data.presets ?? []).filter(s => s.id !== strategyId && s.source !== 'composite'))
}).catch(() => setAllStrategies([]))
}
})
.catch(() => setDetail(null))
.finally(() => setLoading(false))
}, [strategyId])
// 叠加策略: 权重归一(总和→1.0)
const compositeTotal = compositeChildren.reduce((s, c) => s + (c.weight || 0), 0)
const normalizeCompositeWeights = () => {
if (compositeTotal <= 0) return
setCompositeChildren(prev => prev.map(c => ({ ...c, weight: Math.round((c.weight / compositeTotal) * 1000) / 1000 })))
}
const removeCompositeChild = (id: string) => {
setCompositeChildren(prev => prev.filter(c => c.id !== id))
}
const addCompositeChild = (s: { id: string; name: string; source?: string }) => {
setCompositeChildren(prev => [...prev, { id: s.id, name: s.name, source: s.source ?? '', weight: 1.0 }])
setShowAddChild(false)
}
// 保存
const handleSave = async () => {
if (!strategyId) return
@@ -268,6 +294,10 @@ export function StrategySettingsDialog({ strategyId, onClose, onSaved, onAiModif
entry_signals: entrySignals,
exit_signals: exitSignals,
display_limit: displayLimit,
// 叠加策略: 子策略权重(composite 专属, 走 override.children 持久化)
...(detail?.source === 'composite'
? { children: compositeChildren.map(c => ({ strategy_id: c.id, weight: c.weight })) }
: {}),
})
onSaved?.(displayLimit)
onClose()
@@ -298,6 +328,7 @@ export function StrategySettingsDialog({ strategyId, onClose, onSaved, onAiModif
setExitSignals(d.exit_signals ?? [])
setDisplayLimit(d.display_limit ?? null)
setBasicFilterEnabled(d.basic_filter?.enabled !== false)
setCompositeChildren(d.composite_children ?? [])
} finally {
setResetting(false)
}
@@ -347,7 +378,7 @@ export function StrategySettingsDialog({ strategyId, onClose, onSaved, onAiModif
<div className="flex items-center gap-2.5">
<Settings2 className="h-4 w-4 text-accent" />
<span id="strategy-settings-title" className="text-sm font-semibold text-foreground">{detail?.name ?? strategyId}</span>
{detail && <span className="text-[10px] px-1.5 py-0.5 rounded bg-elevated text-muted">{{ builtin: '内置', custom: '自定义', ai: 'AI' }[detail.source] ?? detail.source}</span>}
{detail && <span className="text-[10px] px-1.5 py-0.5 rounded bg-elevated text-muted">{{ builtin: '内置', custom: '自定义', ai: 'AI', composite: '叠加' }[detail.source] ?? detail.source}</span>}
<span className="text-[10px] text-muted/40 font-mono">{strategyId}</span>
</div>
<div className="flex items-center gap-2">
@@ -394,7 +425,87 @@ export function StrategySettingsDialog({ strategyId, onClose, onSaved, onAiModif
</div>
</div>
{/* 三列 */}
{/* 叠加策略: 子策略列表 + 权重(替换三列参数, composite 专属) */}
{detail.source === 'composite' ? (() => {
const SRC_LABEL: Record<string, string> = { builtin: '内置', custom: '自定义', ai: 'AI' }
const SRC_CLS: Record<string, string> = {
builtin: 'border-accent/25 bg-accent/10 text-accent',
custom: 'border-amber-400/25 bg-amber-400/10 text-amber-400',
ai: 'border-purple-500/25 bg-purple-500/10 text-purple-400',
}
const selectedIds = new Set(compositeChildren.map(c => c.id))
const candidates = allStrategies.filter(s => !selectedIds.has(s.id))
return (
<div className="rounded-xl border border-teal-500/20 bg-teal-500/5 p-4 space-y-3">
<div className="flex items-center gap-2">
<Layers className="h-4 w-4 text-teal-400" />
<span className="text-sm font-medium text-foreground"></span>
<span className="text-[10px] text-muted flex items-center gap-1.5">
{compositeChildren.length} · {compositeTotal.toFixed(2)}
{compositeTotal > 0 && Math.abs(compositeTotal - 1) > 0.001 && (
<button onClick={normalizeCompositeWeights} className="text-teal-400 hover:text-teal-300 underline underline-offset-2"></button>
)}
</span>
<button onClick={() => setShowAddChild(v => !v)} className="ml-auto inline-flex items-center gap-1 h-6 px-2 rounded-lg border border-teal-500/30 bg-teal-500/10 text-[11px] text-teal-400 hover:bg-teal-500/20">
<Plus className="h-3 w-3" />
</button>
</div>
{/* 添加子策略面板 */}
{showAddChild && (
<div className="rounded-lg border border-border bg-base/60 p-2 space-y-1 max-h-48 overflow-y-auto">
{candidates.length === 0 ? (
<div className="text-[11px] text-muted py-2 text-center"></div>
) : candidates.map(s => (
<button key={s.id} onClick={() => addCompositeChild(s)} className="flex w-full items-center gap-1.5 rounded px-2 py-1 text-left hover:bg-teal-500/10">
<Plus className="h-3 w-3 shrink-0 text-teal-400" />
<span className="flex-1 truncate text-xs text-foreground">{s.name}</span>
{s.source && (
<span className={`rounded border px-1 text-[8px] ${SRC_CLS[s.source] ?? ''}`}>{SRC_LABEL[s.source] ?? s.source}</span>
)}
</button>
))}
</div>
)}
{compositeChildren.length === 0 ? (
<div className="text-xs text-muted py-4 text-center">, "添加"</div>
) : (
<div className="space-y-1.5">
{compositeChildren.map((c, i) => (
<div key={c.id} className="flex items-center gap-2 rounded-lg bg-base/60 px-3 py-2">
<span className="text-[10px] text-muted/50 font-mono w-5">{i + 1}</span>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1.5">
<span className="text-xs font-medium text-foreground truncate">{c.name || c.id}</span>
{c.source && (
<span className={`rounded border px-1 text-[8px] shrink-0 ${SRC_CLS[c.source] ?? ''}`}>{SRC_LABEL[c.source] ?? c.source}</span>
)}
</div>
<div className="text-[10px] text-muted/50 font-mono">{c.id}</div>
</div>
<div className="flex items-center gap-1 shrink-0">
<input
type="number"
step={0.05}
min={0}
value={c.weight}
onChange={e => setCompositeChildren(prev => prev.map((p, j) => j === i ? { ...p, weight: parseFloat(e.target.value) || 0 } : p))}
className="w-16 h-7 px-1.5 rounded-lg bg-base border border-border/40 text-xs font-mono text-foreground text-center focus:outline-none focus:border-accent/50"
/>
<button onClick={() => removeCompositeChild(c.id)} className="text-danger/50 hover:text-danger p-1">
<Trash2 className="h-3 w-3" />
</button>
</div>
</div>
))}
</div>
)}
<div className="text-[10px] text-muted/60 pt-1 border-t border-border/30">
提示: 权重建议归一为 1.0; "保存设置"
</div>
</div>
)
})()
: (
<div className="grid grid-cols-3 gap-5 items-start">
{/* 列1:选股条件 */}
<Section icon={Filter} title="基础参数" accent="text-sky-400">
@@ -557,6 +668,7 @@ export function StrategySettingsDialog({ strategyId, onClose, onSaved, onAiModif
)}
</div>
</div>
)}
</>
) : (
<div className="flex items-center justify-center py-16 text-sm text-muted"></div>
@@ -570,7 +682,7 @@ export function StrategySettingsDialog({ strategyId, onClose, onSaved, onAiModif
className="inline-flex items-center gap-1.5 h-8 px-3 rounded-lg border border-border bg-surface text-xs text-secondary hover:text-danger hover:border-danger/30 transition-colors cursor-pointer disabled:opacity-50">
<RotateCcw className="h-3.5 w-3.5" />{resetting ? '重置中…' : '重置默认'}
</button>
{(detail?.source === 'ai' || detail?.source === 'custom') && (
{(detail?.source === 'ai' || detail?.source === 'custom' || detail?.source === 'composite') && (
<button onClick={() => { setDeleteError(''); setShowDeleteConfirm(true) }}
className="text-[10px] text-danger hover:text-danger/80 transition-colors"></button>
)}
@@ -1,5 +1,6 @@
import { motion, AnimatePresence } from 'framer-motion'
import { X, Store, Hammer, Download, Zap } from 'lucide-react'
import { useDialogBackdrop } from '@/lib/useDialogBackdrop'
interface Props {
open: boolean
@@ -11,6 +12,7 @@ interface Props {
* 目标:不定时更新更多策略。功能正在建设中,当前仅占位。
*/
export function StrategyStoreDialog({ open, onClose }: Props) {
const backdrop = useDialogBackdrop(onClose)
return (
<AnimatePresence>
{open && (
@@ -19,7 +21,7 @@ export function StrategyStoreDialog({ open, onClose }: Props) {
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50"
onClick={e => { if (e.target === e.currentTarget) onClose() }}
{...backdrop}
>
<motion.div
initial={{ opacity: 0, scale: 0.95, y: 10 }}
+30 -3
View File
@@ -426,13 +426,20 @@ export interface StrategyParamDef {
options?: string[]
}
export interface CompositeChildInfo {
id: string
name: string
source: string
weight: number
}
export interface StrategyDetail {
id: string
name: string
description: string
tags: string[]
source: 'builtin' | 'custom' | 'ai'
execution_backend: 'polars_expr' | 'matrix_native' | 'python_history_legacy'
source: 'builtin' | 'custom' | 'ai' | 'composite'
execution_backend: 'polars_expr' | 'matrix_native' | 'python_history_legacy' | 'composite'
asset_types: string[]
timeframes: string[]
version: string
@@ -454,6 +461,8 @@ export interface StrategyDetail {
order_by: string
descending: boolean
limit: number
// 叠加策略(composite)专属: 子策略列表与合并模式。非 composite 时为 null。
composite_children?: CompositeChildInfo[] | null
}
export interface StrategyBuildResult {
@@ -472,7 +481,7 @@ export type StrategyBuildStreamEvent =
export interface StrategyCodeSaveResult {
ok: boolean
strategy_id: string
source: 'ai' | 'custom'
source: 'ai' | 'custom' | 'composite'
path: string
meta: Record<string, any>
}
@@ -733,6 +742,9 @@ export interface StrategyBacktestResult {
score_max: number | null
max_hold_days: number | null
source: string
execution_backend?: string
// 叠加策略回测: 子策略构成与权重归因
composite_children?: { id: string; weight: number }[]
}
elapsed_ms: number
error: string | null
@@ -2186,6 +2198,21 @@ export const api = {
body: JSON.stringify(payload),
}),
/** 创建/更新叠加策略(composite): 声明式引用多个子策略 */
strategySaveComposite: (payload: {
strategy_id: string
name: string
description?: string
children: { strategy_id: string; weight: number }[]
merge_mode: 'union' | 'intersect'
min_confirm?: number
mode: 'create' | 'update'
}) =>
request<StrategyCodeSaveResult>('/api/strategies/composite/save', {
method: 'POST',
body: JSON.stringify(payload),
}),
/** 保存 AI 生成的策略文件 */
strategySaveCode: (strategyId: string, code: string, meta?: { name?: string; description?: string }) =>
request<{ ok: boolean; path: string }>('/api/strategies/ai/save', {
+21
View File
@@ -22,6 +22,7 @@ import { StrategySettingsDialog } from '@/components/screener/StrategySettingsDi
import { StrategyPoolDialog } from '@/components/screener/StrategyPoolDialog'
import { StrategyBuilderDialog } from '@/components/screener/StrategyBuilderDialog'
import { StrategyStoreDialog } from '@/components/screener/StrategyStoreDialog'
import { CompositeStrategyDialog } from '@/components/screener/CompositeStrategyDialog'
import { ListColumnCustomizer } from '@/components/ListColumnCustomizer'
import { useTableSort } from '@/components/stock-table/useTableSort'
import { resolveCandleConfig } from '@/lib/list-columns'
@@ -48,6 +49,7 @@ export function Screener() {
const [showBuilder, setShowBuilder] = useState(false)
const [builderMode, setBuilderMode] = useState<'create' | 'modify'>('create')
const [showStore, setShowStore] = useState(false)
const [showComposite, setShowComposite] = useState(false)
const { pool, addToPool, removeFromPool, reorderPool, prune } = useStrategyPool()
const [cardSize, setCardSize] = useState<CardSize>(loadCardSize)
// 日k蜡烛图显示开关(仅当 candle 列可见时才有意义;持久化)
@@ -666,6 +668,16 @@ export function Screener() {
{visiblePool.length}/{strategyPresets.length}
</span>
</button>
{/* 创建叠加策略 */}
<button
onClick={() => setShowComposite(true)}
className="inline-flex items-center gap-1.5 h-7 px-3 rounded-btn
text-xs font-medium text-teal-400 border border-teal-500/20 bg-teal-500/5
hover:bg-teal-500/15 transition-colors cursor-pointer"
>
<Layers className="h-3.5 w-3.5" />
</button>
{/* 创建策略 */}
<button
onClick={() => { setBuilderMode('create'); setShowBuilder(true) }}
@@ -995,6 +1007,15 @@ export function Screener() {
}}
/>
<CompositeStrategyDialog
open={showComposite}
onClose={() => setShowComposite(false)}
onSavedId={async id => {
await qc.fetchQuery({ queryKey: QK.screenerStrategies('all'), queryFn: () => api.screenerStrategies(), staleTime: 0 })
addToPool(id)
}}
/>
<StrategyStoreDialog
open={showStore}
onClose={() => setShowStore(false)}
@@ -1,7 +1,7 @@
import { useState, useMemo, useEffect, useRef, type ReactNode } from 'react'
import { useQuery } from '@tanstack/react-query'
import { motion, AnimatePresence } from 'framer-motion'
import { Play, FlaskConical, Clock, Loader2, Square, Search, Plus, X, SlidersHorizontal, BarChart3, Gauge, Zap, ListPlus, HelpCircle, ChevronRight, AlertTriangle } from 'lucide-react'
import { Play, FlaskConical, Clock, Loader2, Square, Search, Plus, X, SlidersHorizontal, BarChart3, Gauge, Zap, ListPlus, HelpCircle, ChevronRight, AlertTriangle, Layers } from 'lucide-react'
import {
api,
type StrategyBacktestResult,
@@ -149,12 +149,13 @@ function FillRuleHint() {
)
}
const SRC_MAP: Record<string, string> = { builtin: '内置', custom: '自定义', ai: 'AI' }
const SRC_MAP: Record<string, string> = { builtin: '内置', custom: '自定义', ai: 'AI', composite: '叠加' }
const TRADE_PAGE_SIZE_OPTIONS = [10, 20, 30, 50, 100]
const BADGE_CLS_MAP: Record<string, string> = {
builtin: 'bg-secondary/10 text-muted border-border',
ai: 'bg-purple-500/10 text-purple-400 border-purple-500/30',
custom: 'bg-amber-400/10 text-amber-400 border-amber-400/30',
composite: 'bg-teal-500/10 text-teal-400 border-teal-500/30',
}
const FIELD_LABEL: Record<string, string> = {}
for (const c of BUILTIN_COLUMNS) {
@@ -179,11 +180,12 @@ const BASIC_FILTER_FIELDS = [
{ key: 'turnover_max', label: '最高换手率', unit: '%' },
]
type AdvancedSettingsTab = 'params' | 'filter' | 'entry' | 'exit' | 'scoring' | 'risk' | 'range'
type StrategyGroup = 'all' | 'custom' | 'ai' | 'builtin'
type StrategyGroup = 'all' | 'custom' | 'ai' | 'builtin' | 'composite'
const STRATEGY_GROUPS: { id: StrategyGroup; label: string }[] = [
{ id: 'all', label: '全部' },
{ id: 'custom', label: '自定义' },
{ id: 'ai', label: 'AI' },
{ id: 'composite', label: '叠加' },
{ id: 'builtin', label: '内置' },
]
const ADVANCED_TABS: { id: AdvancedSettingsTab; label: string }[] = [
@@ -1189,11 +1191,15 @@ export function StrategyBacktest() {
const detail = strategyDetail.data
const matrixStrategy = detail?.execution_backend === 'matrix_native'
const compositeStrategy = detail?.source === 'composite'
const visibleAdvancedTabs = useMemo(
() => matrixStrategy
? ADVANCED_TABS.filter(tab => tab.id !== 'entry' && tab.id !== 'exit')
: compositeStrategy
// composite 的 entry/exit/scoring 由子策略决定, composite 层只调合并参数(params Tab)
? ADVANCED_TABS.filter(tab => tab.id !== 'entry' && tab.id !== 'exit' && tab.id !== 'scoring')
: ADVANCED_TABS,
[matrixStrategy],
[matrixStrategy, compositeStrategy],
)
const basicFilter = (overrides.basic_filter ?? {}) as Record<string, any>
const entrySignals = (overrides.entry_signals ?? []) as string[]
@@ -1864,6 +1870,18 @@ export function StrategyBacktest() {
</span>
)}
</div>
{/* 叠加策略: 子策略构成归因 */}
{result.strategy_info.composite_children && result.strategy_info.composite_children.length > 0 && (
<div className="flex items-center gap-1.5 flex-wrap">
<Layers className="h-3 w-3 text-teal-400 shrink-0" />
<span className="text-[10px] text-muted"> {result.strategy_info.composite_children.length} </span>
{result.strategy_info.composite_children.map(c => (
<span key={c.id} className="text-[9px] px-1.5 py-px rounded border border-teal-500/25 bg-teal-500/10 text-teal-400">
{c.id}<span className="text-teal-400/60 ml-0.5">{(c.weight * 100).toFixed(0)}%</span>
</span>
))}
</div>
)}
{result.strategy_info.stop_loss != null && (
<span className="text-[10px] text-secondary"> {fmtPct(result.strategy_info.stop_loss)}</span>
)}