diff --git a/backend/app/api/strategy.py b/backend/app/api/strategy.py index 42c8df4..6e40c1c 100644 --- a/backend/app/api/strategy.py +++ b/backend/app/api/strategy.py @@ -118,7 +118,22 @@ def _safe(result_dict: dict) -> dict: return result_dict -def _strategy_detail(s: StrategyDef, overrides: dict | None = None) -> dict: +def _child_meta(engine: StrategyEngine | None, child_id: str) -> dict: + """查询子策略的可读名称与来源; engine 缺失或子策略不存在时回退为 id/unknown。""" + if engine is None: + return {"name": child_id, "source": "unknown"} + try: + child = engine.get(child_id) + except ValueError: + return {"name": child_id, "source": "unknown"} + return {"name": str(child.meta.get("name") or child_id), "source": child.source} + + +def _strategy_detail( + s: StrategyDef, + overrides: dict | None = None, + engine: StrategyEngine | None = None, +) -> dict: """策略详情(含用户覆盖)""" bf = {**s.basic_filter} scoring = dict(s.meta.get("scoring", {})) @@ -165,6 +180,19 @@ def _strategy_detail(s: StrategyDef, overrides: dict | None = None) -> dict: "descending": s.meta.get("descending", True), "limit": s.meta.get("limit", 30), "display_limit": overrides.get("display_limit") if overrides and "display_limit" in overrides else None, + # 叠加策略: 子策略列表与权重(供前端展示)。override.children 可覆盖 META 固化值。 + "composite_children": ( + [ + { + "id": c["strategy_id"], + **_child_meta(engine, c["strategy_id"]), + "weight": c.get("weight", 1.0), + } + for c in (overrides.get("children") if overrides and isinstance(overrides.get("children"), list) else s.meta.get("children", [])) + ] + if s.execution_backend == "composite" + else None + ), } @@ -218,6 +246,21 @@ class StrategyCodeSaveRequest(BaseModel): description: str = "" +class CompositeChildItem(BaseModel): + strategy_id: str + weight: float = 1.0 + + +class StrategyCompositeSaveRequest(BaseModel): + strategy_id: str + name: str = "" + description: str = "" + children: list[CompositeChildItem] + merge_mode: Literal["union", "intersect"] = "union" + min_confirm: int = 0 + mode: Literal["create", "update"] = "create" + + class MonitorStartRequest(BaseModel): strategy_id: str @@ -244,7 +287,7 @@ def list_strategies( sid = meta["id"] s = engine.get(sid) overrides = all_overrides.get(sid) - result.append(_strategy_detail(s, overrides)) + result.append(_strategy_detail(s, overrides, engine)) return {"strategies": result, "load_errors": engine.load_errors()} @@ -256,7 +299,7 @@ def get_strategy(strategy_id: str, request: Request): except ValueError as e: raise HTTPException(status_code=404, detail=str(e)) from e overrides = strategy_config.load_override(_data_dir(request), strategy_id) - return _strategy_detail(s, overrides or None) + return _strategy_detail(s, overrides or None, engine) # ── 执行选股 ───────────────────────────────────────────────────────── @@ -534,8 +577,8 @@ def _validate_strategy_id(strategy_id: str) -> str: def _target_dir(data_dir: Path, source: str) -> Path: - if source not in {"ai", "custom"}: - raise ValueError("target_source 必须是 ai 或 custom") + if source not in {"ai", "custom", "composite"}: + raise ValueError("target_source 必须是 ai、custom 或 composite") return data_dir / "strategies" / source @@ -796,6 +839,135 @@ def save_strategy_code(req: StrategyCodeSaveRequest, request: Request): raise HTTPException(status_code=400, detail=str(e)) from e +def _render_composite_code( + sid: str, + name: str, + description: str, + children: list[dict], + merge_mode: str, + min_confirm: int, +) -> str: + """渲染声明式 composite 策略 .py 文件内容。 + + composite 不含业务代码, 仅通过 META.children 引用子策略 + EXECUTION_BACKEND 声明。 + 权重固化在 META; merge_mode/min_confirm 作为 params(可经 override 轻量调整)。 + """ + import json as _json + + children_json = ",\n ".join( + _json.dumps({"strategy_id": c["strategy_id"], "weight": c["weight"]}, ensure_ascii=False) + for c in children + ) + safe_name = name or sid + return f'''"""叠加策略 {sid}(自动生成, 请勿手改业务逻辑)。""" +META = {{ + "id": {sid!r}, + "name": {safe_name!r}, + "description": {description!r}, + "asset_types": ["stock"], + "timeframes": ["1d"], + "params": [ + {{"id": "merge_mode", "label": "合并模式", "type": "select", + "options": ["union", "intersect"], "default": {merge_mode!r}}}, + {{"id": "min_confirm", "label": "交集最少确认数", "type": "int", + "default": {int(min_confirm)!r}, "min": 0}}, + ], + "scoring": {{}}, + "order_by": "score", + "descending": True, + "limit": 100, + "children": [ + {children_json} + ], +}} +EXECUTION_BACKEND = "composite" +''' + + +def _save_composite_strategy(req: StrategyCompositeSaveRequest, request: Request) -> dict: + """保存叠加策略: 渲染声明式 .py → 写盘 → reload → 校验。""" + sid = _validate_strategy_id(req.strategy_id) + if not sid.startswith("composite_"): + raise ValueError("叠加策略 ID 必须以 composite_ 开头") + + engine = _get_engine(request) + data_dir = _data_dir(request) + + existing: StrategyDef | None = None + try: + existing = engine.get(sid) + except ValueError: + existing = None + + if req.mode == "create": + if existing is not None: + raise ValueError(f"策略 {sid} 已存在,请改用修改模式或换一个策略 ID") + else: # update + if existing is None: + raise ValueError(f"策略 {sid} 不存在") + if existing.source == "builtin": + raise ValueError("内置策略不可覆盖") + # 只允许覆盖 composite 策略(防止把普通策略覆盖成 composite) + if existing.execution_backend != "composite": + raise ValueError("目标策略不是叠加策略,无法以叠加模式覆盖") + + if not req.children: + raise ValueError("叠加策略至少需要一个子策略") + + children = [{"strategy_id": c.strategy_id, "weight": c.weight} for c in req.children] + # 子策略存在性预检(给出清晰错误, 而非等到 reload 后孤儿移除的笼统报错)。 + for c in children: + if not engine.has(c["strategy_id"]): + raise ValueError(f"子策略 {c['strategy_id']!r} 不存在") + try: + child_def = engine.get(c["strategy_id"]) + if child_def.execution_backend == "composite": + raise ValueError(f"子策略 {c['strategy_id']!r} 也是叠加策略; 首版禁止嵌套叠加") + except ValueError: + raise + + code = _render_composite_code( + sid, req.name, req.description, children, req.merge_mode, req.min_confirm + ) + + out_dir = _target_dir(data_dir, "composite") + out_dir.mkdir(parents=True, exist_ok=True) + path = out_dir / f"{sid}.py" + previous_code = path.read_text(encoding="utf-8") if path.exists() else None + path.write_text(code, encoding="utf-8") + + try: + engine.reload() + loaded = engine.get(sid) + if loaded.file_path is None or loaded.file_path.resolve() != path.resolve(): + raise ValueError("策略加载到了非预期文件,请检查是否存在重复 strategy_id") + if loaded.source != "composite": + raise ValueError(f"策略来源异常: 期望 composite, 实际 {loaded.source}") + if loaded.execution_backend != "composite": + raise ValueError("策略后端异常: 期望 composite") + except Exception as e: + _restore_strategy_file(path, previous_code) + engine.reload() + raise ValueError(f"叠加策略保存失败: {e}") from e + + _invalidate_strategy_runtime(request) + + return { + "ok": True, + "strategy_id": sid, + "source": "composite", + "path": str(path), + } + + +@router.post("/composite/save") +def save_composite_strategy(req: StrategyCompositeSaveRequest, request: Request): + try: + return _save_composite_strategy(req, request) + except Exception as e: + raise HTTPException(status_code=400, detail=str(e)) from e + + @router.post("/ai/save") async def ai_save(req: AISaveRequest, request: Request): try: @@ -827,9 +999,17 @@ def delete_strategy(strategy_id: str, request: Request): if s.source == "builtin": raise HTTPException(status_code=403, detail="内置策略不可删除") + # 删除被引用的子策略会令叠加策略加载失败; 删除前 fail-closed 阻止。 + dependents = engine.find_dependents(strategy_id) + if dependents: + raise HTTPException( + status_code=409, + detail=f"该策略被叠加策略 {dependents} 引用,请先解除引用后再删除", + ) + path = s.file_path data_dir = _data_dir(request) - if path is None or s.source not in {"custom", "ai"}: + if path is None or s.source not in {"custom", "ai", "composite"}: raise HTTPException(status_code=400, detail="策略源文件路径无效, 无法删除") try: diff --git a/backend/app/backtest/strategy.py b/backend/app/backtest/strategy.py index ce17fa4..b938d1a 100644 --- a/backend/app/backtest/strategy.py +++ b/backend/app/backtest/strategy.py @@ -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,15 +961,23 @@ class StrategyBacktestService: ) try: - feature_plan = StrategyDependencyResolver().resolve( - s, - params=params, - basic_filter=basic_filter, - entry_signals=entry_signals, - exit_signals=exit_signals, - overrides=overrides, - minute_fill=config.minute_fill, - ) + 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, + basic_filter=basic_filter, + entry_signals=entry_signals, + exit_signals=exit_signals, + overrides=overrides, + minute_fill=config.minute_fill, + ) except ValueError as e: return _err(str(e)) @@ -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) diff --git a/backend/app/backtest/walkforward.py b/backend/app/backtest/walkforward.py index 0f61a1a..dfdb7c1 100644 --- a/backend/app/backtest/walkforward.py +++ b/backend/app/backtest/walkforward.py @@ -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 diff --git a/backend/app/backtest/worker.py b/backend/app/backtest/worker.py index ba35c48..1da69c6 100644 --- a/backend/app/backtest/worker.py +++ b/backend/app/backtest/worker.py @@ -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: diff --git a/backend/app/main.py b/backend/app/main.py index 1f801ab..13232fc 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -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())) diff --git a/backend/app/strategy/composite.py b/backend/app/strategy/composite.py new file mode 100644 index 0000000..d0877b1 --- /dev/null +++ b/backend/app/strategy/composite.py @@ -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, + ) diff --git a/backend/app/strategy/engine.py b/backend/app/strategy/engine.py index ecbcc71..1d3d4c2 100644 --- a/backend/app/strategy/engine.py +++ b/backend/app/strategy/engine.py @@ -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, diff --git a/backend/app/strategy/monitor.py b/backend/app/strategy/monitor.py index ed49b78..22e73c3 100644 --- a/backend/app/strategy/monitor.py +++ b/backend/app/strategy/monitor.py @@ -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: diff --git a/backend/tests/backtest/test_composite_backtest_e2e.py b/backend/tests/backtest/test_composite_backtest_e2e.py new file mode 100644 index 0000000..94b28a8 --- /dev/null +++ b/backend/tests/backtest/test_composite_backtest_e2e.py @@ -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 diff --git a/backend/tests/test_composite_override.py b/backend/tests/test_composite_override.py new file mode 100644 index 0000000..b8cf58e --- /dev/null +++ b/backend/tests/test_composite_override.py @@ -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 diff --git a/backend/tests/test_composite_strategy.py b/backend/tests/test_composite_strategy.py new file mode 100644 index 0000000..52eb788 --- /dev/null +++ b/backend/tests/test_composite_strategy.py @@ -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") diff --git a/backend/tests/test_composite_strategy_api.py b/backend/tests/test_composite_strategy_api.py new file mode 100644 index 0000000..6e24d2f --- /dev/null +++ b/backend/tests/test_composite_strategy_api.py @@ -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") diff --git a/frontend/src/components/monitor/RuleEditor.tsx b/frontend/src/components/monitor/RuleEditor.tsx index 320a663..58d7639 100644 --- a/frontend/src/components/monitor/RuleEditor.tsx +++ b/frontend/src/components/monitor/RuleEditor.tsx @@ -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 => ({ @@ -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[]) => { diff --git a/frontend/src/components/screener/CompositeStrategyDialog.tsx b/frontend/src/components/screener/CompositeStrategyDialog.tsx new file mode 100644 index 0000000..bd0d37e --- /dev/null +++ b/frontend/src/components/screener/CompositeStrategyDialog.tsx @@ -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 + /** 编辑模式: 传入已有 composite 策略 id 时为 update, 否则 create */ + editStrategyId?: string | null +} + +interface ChildItem { + strategy_id: string + weight: number +} + +const SRC_MAP: Record = { builtin: '内置', custom: '自定义', ai: 'AI' } +const BADGE_CLS: Record = { + 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([]) + 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([]) + 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 ( + + {open && ( + { + mouseDownOnBackdrop.current = e.target === e.currentTarget + }} + onClick={(e) => { + // 仅当按下和松开都在遮罩上才关闭, 避免面板内拖选文本误关。 + if (mouseDownOnBackdrop.current && e.target === e.currentTarget) onClose() + }} + > + e.stopPropagation()} + > + {/* 头部 */} +
+ + + {isEdit ? '编辑叠加策略' : '创建叠加策略'} + + + 引用多个子策略, 合并选股与回测信号 + + +
+ + {/* 主体 */} +
+ {/* 基本信息 */} +
+
+ + +
+
+ + 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" + /> +
+
+
+ + 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" + /> +
+ + {/* 合并模式 */} +
+
+ + +
+
+ + 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" + /> +
+
+ + {/* 已选子策略 */} +
+
+ + + 权重总和: {totalWeight.toFixed(2)} + {totalWeight > 0 && Math.abs(totalWeight - 1) > 0.001 && ( + + )} + +
+
+ {children.length === 0 && ( +
+ 从下方列表选择子策略 +
+ )} + {children.map(c => { + const s = available.find(a => a.id === c.strategy_id) + return ( +
+ {s?.name ?? c.strategy_id} + {s?.source && ( + + {SRC_MAP[s.source] ?? s.source} + + )} + 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" + /> + +
+ ) + })} +
+
+ + {/* 可选子策略 */} +
+ +
+ + 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" + /> +
+
+ {loadingList && ( +
+ 加载中 +
+ )} + {!loadingList && filteredAvailable.length === 0 && ( +
无可用策略
+ )} + {filteredAvailable.map(s => ( + + ))} +
+
+ + {error && ( +
+ {error} +
+ )} +
+ + {/* 底部 */} +
+ + +
+
+
+ )} +
+ ) +} diff --git a/frontend/src/components/screener/StrategyCard.tsx b/frontend/src/components/screener/StrategyCard.tsx index 9dc8547..f2efbff 100644 --- a/frontend/src/components/screener/StrategyCard.tsx +++ b/frontend/src/components/screener/StrategyCard.tsx @@ -63,11 +63,12 @@ export function cardWrapCls(size: CardSize): string { // ===== 来源标签 ===== -const SRC_MAP: Record = { builtin: '内置', custom: '自定义', ai: 'AI' } +const SRC_MAP: Record = { builtin: '内置', custom: '自定义', ai: 'AI', composite: '叠加' } const BADGE_CLS_MAP: Record = { 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', } // ===== 策略卡片 ===== diff --git a/frontend/src/components/screener/StrategyPoolDialog.tsx b/frontend/src/components/screener/StrategyPoolDialog.tsx index 9eae1f5..d6dcc21 100644 --- a/frontend/src/components/screener/StrategyPoolDialog.tsx +++ b/frontend/src/components/screener/StrategyPoolDialog.tsx @@ -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(() => [...pool]) const [allStrategies, setAllStrategies] = useState([]) @@ -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} > ([]) const [displayLimit, setDisplayLimit] = useState(null) const [basicFilterEnabled, setBasicFilterEnabled] = useState(true) + // 叠加策略: 子策略列表与权重(composite 专属, 编辑权重后随 override 保存) + const [compositeChildren, setCompositeChildren] = useState([]) + // 可选子策略列表 + 添加面板开关(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
{detail?.name ?? strategyId} - {detail && {{ builtin: '内置', custom: '自定义', ai: 'AI' }[detail.source] ?? detail.source}} + {detail && {{ builtin: '内置', custom: '自定义', ai: 'AI', composite: '叠加' }[detail.source] ?? detail.source}} {strategyId}
@@ -394,7 +425,87 @@ export function StrategySettingsDialog({ strategyId, onClose, onSaved, onAiModif
- {/* 三列 */} + {/* 叠加策略: 子策略列表 + 权重(替换三列参数, composite 专属) */} + {detail.source === 'composite' ? (() => { + const SRC_LABEL: Record = { builtin: '内置', custom: '自定义', ai: 'AI' } + const SRC_CLS: Record = { + 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 ( +
+
+ + 子策略与权重 + + 共 {compositeChildren.length} 个 · 权重总和 {compositeTotal.toFixed(2)} + {compositeTotal > 0 && Math.abs(compositeTotal - 1) > 0.001 && ( + + )} + + +
+ {/* 添加子策略面板 */} + {showAddChild && ( +
+ {candidates.length === 0 ? ( +
无可添加的策略
+ ) : candidates.map(s => ( + + ))} +
+ )} + {compositeChildren.length === 0 ? ( +
暂无子策略, 点击"添加"选择
+ ) : ( +
+ {compositeChildren.map((c, i) => ( +
+ {i + 1} +
+
+ {c.name || c.id} + {c.source && ( + {SRC_LABEL[c.source] ?? c.source} + )} +
+
{c.id}
+
+
+ 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" + /> + +
+
+ ))} +
+ )} +
+ 提示: 权重建议归一为 1.0; 修改后点底部"保存设置"生效。 +
+
+ ) + })() + : (
{/* 列1:选股条件 */}
@@ -557,6 +668,7 @@ export function StrategySettingsDialog({ strategyId, onClose, onSaved, onAiModif )}
+ )} ) : (
加载失败
@@ -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"> {resetting ? '重置中…' : '重置默认'} - {(detail?.source === 'ai' || detail?.source === 'custom') && ( + {(detail?.source === 'ai' || detail?.source === 'custom' || detail?.source === 'composite') && ( )} diff --git a/frontend/src/components/screener/StrategyStoreDialog.tsx b/frontend/src/components/screener/StrategyStoreDialog.tsx index 8dad99a..dc715dc 100644 --- a/frontend/src/components/screener/StrategyStoreDialog.tsx +++ b/frontend/src/components/screener/StrategyStoreDialog.tsx @@ -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 ( {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} > } @@ -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('/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', { diff --git a/frontend/src/pages/Screener.tsx b/frontend/src/pages/Screener.tsx index e60c0f3..c195a63 100644 --- a/frontend/src/pages/Screener.tsx +++ b/frontend/src/pages/Screener.tsx @@ -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(loadCardSize) // 日k蜡烛图显示开关(仅当 candle 列可见时才有意义;持久化) @@ -666,6 +668,16 @@ export function Screener() { {visiblePool.length}/{strategyPresets.length} + {/* 创建叠加策略 */} + {/* 创建策略 */}