mirror of
https://ghfast.top/https://github.com/aeroxw/tick-stock-panel.git
synced 2026-09-12 19:04:15 +08:00
Merge pull request #155 from shy3130/feat/composite-strategy
feat: 新增叠加策略(composite)支持选股与回测
This commit is contained in:
+186
-6
@@ -118,7 +118,22 @@ def _safe(result_dict: dict) -> dict:
|
||||
return result_dict
|
||||
|
||||
|
||||
def _strategy_detail(s: StrategyDef, overrides: dict | None = None) -> dict:
|
||||
def _child_meta(engine: StrategyEngine | None, child_id: str) -> dict:
|
||||
"""查询子策略的可读名称与来源; engine 缺失或子策略不存在时回退为 id/unknown。"""
|
||||
if engine is None:
|
||||
return {"name": child_id, "source": "unknown"}
|
||||
try:
|
||||
child = engine.get(child_id)
|
||||
except ValueError:
|
||||
return {"name": child_id, "source": "unknown"}
|
||||
return {"name": str(child.meta.get("name") or child_id), "source": child.source}
|
||||
|
||||
|
||||
def _strategy_detail(
|
||||
s: StrategyDef,
|
||||
overrides: dict | None = None,
|
||||
engine: StrategyEngine | None = None,
|
||||
) -> dict:
|
||||
"""策略详情(含用户覆盖)"""
|
||||
bf = {**s.basic_filter}
|
||||
scoring = dict(s.meta.get("scoring", {}))
|
||||
@@ -165,6 +180,19 @@ def _strategy_detail(s: StrategyDef, overrides: dict | None = None) -> dict:
|
||||
"descending": s.meta.get("descending", True),
|
||||
"limit": s.meta.get("limit", 30),
|
||||
"display_limit": overrides.get("display_limit") if overrides and "display_limit" in overrides else None,
|
||||
# 叠加策略: 子策略列表与权重(供前端展示)。override.children 可覆盖 META 固化值。
|
||||
"composite_children": (
|
||||
[
|
||||
{
|
||||
"id": c["strategy_id"],
|
||||
**_child_meta(engine, c["strategy_id"]),
|
||||
"weight": c.get("weight", 1.0),
|
||||
}
|
||||
for c in (overrides.get("children") if overrides and isinstance(overrides.get("children"), list) else s.meta.get("children", []))
|
||||
]
|
||||
if s.execution_backend == "composite"
|
||||
else None
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
@@ -218,6 +246,21 @@ class StrategyCodeSaveRequest(BaseModel):
|
||||
description: str = ""
|
||||
|
||||
|
||||
class CompositeChildItem(BaseModel):
|
||||
strategy_id: str
|
||||
weight: float = 1.0
|
||||
|
||||
|
||||
class StrategyCompositeSaveRequest(BaseModel):
|
||||
strategy_id: str
|
||||
name: str = ""
|
||||
description: str = ""
|
||||
children: list[CompositeChildItem]
|
||||
merge_mode: Literal["union", "intersect"] = "union"
|
||||
min_confirm: int = 0
|
||||
mode: Literal["create", "update"] = "create"
|
||||
|
||||
|
||||
class MonitorStartRequest(BaseModel):
|
||||
strategy_id: str
|
||||
|
||||
@@ -244,7 +287,7 @@ def list_strategies(
|
||||
sid = meta["id"]
|
||||
s = engine.get(sid)
|
||||
overrides = all_overrides.get(sid)
|
||||
result.append(_strategy_detail(s, overrides))
|
||||
result.append(_strategy_detail(s, overrides, engine))
|
||||
return {"strategies": result, "load_errors": engine.load_errors()}
|
||||
|
||||
|
||||
@@ -256,7 +299,7 @@ def get_strategy(strategy_id: str, request: Request):
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e)) from e
|
||||
overrides = strategy_config.load_override(_data_dir(request), strategy_id)
|
||||
return _strategy_detail(s, overrides or None)
|
||||
return _strategy_detail(s, overrides or None, engine)
|
||||
|
||||
|
||||
# ── 执行选股 ─────────────────────────────────────────────────────────
|
||||
@@ -534,8 +577,8 @@ def _validate_strategy_id(strategy_id: str) -> str:
|
||||
|
||||
|
||||
def _target_dir(data_dir: Path, source: str) -> Path:
|
||||
if source not in {"ai", "custom"}:
|
||||
raise ValueError("target_source 必须是 ai 或 custom")
|
||||
if source not in {"ai", "custom", "composite"}:
|
||||
raise ValueError("target_source 必须是 ai、custom 或 composite")
|
||||
return data_dir / "strategies" / source
|
||||
|
||||
|
||||
@@ -796,6 +839,135 @@ def save_strategy_code(req: StrategyCodeSaveRequest, request: Request):
|
||||
raise HTTPException(status_code=400, detail=str(e)) from e
|
||||
|
||||
|
||||
def _render_composite_code(
|
||||
sid: str,
|
||||
name: str,
|
||||
description: str,
|
||||
children: list[dict],
|
||||
merge_mode: str,
|
||||
min_confirm: int,
|
||||
) -> str:
|
||||
"""渲染声明式 composite 策略 .py 文件内容。
|
||||
|
||||
composite 不含业务代码, 仅通过 META.children 引用子策略 + EXECUTION_BACKEND 声明。
|
||||
权重固化在 META; merge_mode/min_confirm 作为 params(可经 override 轻量调整)。
|
||||
"""
|
||||
import json as _json
|
||||
|
||||
children_json = ",\n ".join(
|
||||
_json.dumps({"strategy_id": c["strategy_id"], "weight": c["weight"]}, ensure_ascii=False)
|
||||
for c in children
|
||||
)
|
||||
safe_name = name or sid
|
||||
return f'''"""叠加策略 {sid}(自动生成, 请勿手改业务逻辑)。"""
|
||||
META = {{
|
||||
"id": {sid!r},
|
||||
"name": {safe_name!r},
|
||||
"description": {description!r},
|
||||
"asset_types": ["stock"],
|
||||
"timeframes": ["1d"],
|
||||
"params": [
|
||||
{{"id": "merge_mode", "label": "合并模式", "type": "select",
|
||||
"options": ["union", "intersect"], "default": {merge_mode!r}}},
|
||||
{{"id": "min_confirm", "label": "交集最少确认数", "type": "int",
|
||||
"default": {int(min_confirm)!r}, "min": 0}},
|
||||
],
|
||||
"scoring": {{}},
|
||||
"order_by": "score",
|
||||
"descending": True,
|
||||
"limit": 100,
|
||||
"children": [
|
||||
{children_json}
|
||||
],
|
||||
}}
|
||||
EXECUTION_BACKEND = "composite"
|
||||
'''
|
||||
|
||||
|
||||
def _save_composite_strategy(req: StrategyCompositeSaveRequest, request: Request) -> dict:
|
||||
"""保存叠加策略: 渲染声明式 .py → 写盘 → reload → 校验。"""
|
||||
sid = _validate_strategy_id(req.strategy_id)
|
||||
if not sid.startswith("composite_"):
|
||||
raise ValueError("叠加策略 ID 必须以 composite_ 开头")
|
||||
|
||||
engine = _get_engine(request)
|
||||
data_dir = _data_dir(request)
|
||||
|
||||
existing: StrategyDef | None = None
|
||||
try:
|
||||
existing = engine.get(sid)
|
||||
except ValueError:
|
||||
existing = None
|
||||
|
||||
if req.mode == "create":
|
||||
if existing is not None:
|
||||
raise ValueError(f"策略 {sid} 已存在,请改用修改模式或换一个策略 ID")
|
||||
else: # update
|
||||
if existing is None:
|
||||
raise ValueError(f"策略 {sid} 不存在")
|
||||
if existing.source == "builtin":
|
||||
raise ValueError("内置策略不可覆盖")
|
||||
# 只允许覆盖 composite 策略(防止把普通策略覆盖成 composite)
|
||||
if existing.execution_backend != "composite":
|
||||
raise ValueError("目标策略不是叠加策略,无法以叠加模式覆盖")
|
||||
|
||||
if not req.children:
|
||||
raise ValueError("叠加策略至少需要一个子策略")
|
||||
|
||||
children = [{"strategy_id": c.strategy_id, "weight": c.weight} for c in req.children]
|
||||
# 子策略存在性预检(给出清晰错误, 而非等到 reload 后孤儿移除的笼统报错)。
|
||||
for c in children:
|
||||
if not engine.has(c["strategy_id"]):
|
||||
raise ValueError(f"子策略 {c['strategy_id']!r} 不存在")
|
||||
try:
|
||||
child_def = engine.get(c["strategy_id"])
|
||||
if child_def.execution_backend == "composite":
|
||||
raise ValueError(f"子策略 {c['strategy_id']!r} 也是叠加策略; 首版禁止嵌套叠加")
|
||||
except ValueError:
|
||||
raise
|
||||
|
||||
code = _render_composite_code(
|
||||
sid, req.name, req.description, children, req.merge_mode, req.min_confirm
|
||||
)
|
||||
|
||||
out_dir = _target_dir(data_dir, "composite")
|
||||
out_dir.mkdir(parents=True, exist_ok=True)
|
||||
path = out_dir / f"{sid}.py"
|
||||
previous_code = path.read_text(encoding="utf-8") if path.exists() else None
|
||||
path.write_text(code, encoding="utf-8")
|
||||
|
||||
try:
|
||||
engine.reload()
|
||||
loaded = engine.get(sid)
|
||||
if loaded.file_path is None or loaded.file_path.resolve() != path.resolve():
|
||||
raise ValueError("策略加载到了非预期文件,请检查是否存在重复 strategy_id")
|
||||
if loaded.source != "composite":
|
||||
raise ValueError(f"策略来源异常: 期望 composite, 实际 {loaded.source}")
|
||||
if loaded.execution_backend != "composite":
|
||||
raise ValueError("策略后端异常: 期望 composite")
|
||||
except Exception as e:
|
||||
_restore_strategy_file(path, previous_code)
|
||||
engine.reload()
|
||||
raise ValueError(f"叠加策略保存失败: {e}") from e
|
||||
|
||||
_invalidate_strategy_runtime(request)
|
||||
|
||||
return {
|
||||
"ok": True,
|
||||
"strategy_id": sid,
|
||||
"source": "composite",
|
||||
"path": str(path),
|
||||
}
|
||||
|
||||
|
||||
@router.post("/composite/save")
|
||||
def save_composite_strategy(req: StrategyCompositeSaveRequest, request: Request):
|
||||
try:
|
||||
return _save_composite_strategy(req, request)
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=400, detail=str(e)) from e
|
||||
|
||||
|
||||
@router.post("/ai/save")
|
||||
async def ai_save(req: AISaveRequest, request: Request):
|
||||
try:
|
||||
@@ -827,9 +999,17 @@ def delete_strategy(strategy_id: str, request: Request):
|
||||
if s.source == "builtin":
|
||||
raise HTTPException(status_code=403, detail="内置策略不可删除")
|
||||
|
||||
# 删除被引用的子策略会令叠加策略加载失败; 删除前 fail-closed 阻止。
|
||||
dependents = engine.find_dependents(strategy_id)
|
||||
if dependents:
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail=f"该策略被叠加策略 {dependents} 引用,请先解除引用后再删除",
|
||||
)
|
||||
|
||||
path = s.file_path
|
||||
data_dir = _data_dir(request)
|
||||
if path is None or s.source not in {"custom", "ai"}:
|
||||
if path is None or s.source not in {"custom", "ai", "composite"}:
|
||||
raise HTTPException(status_code=400, detail="策略源文件路径无效, 无法删除")
|
||||
|
||||
try:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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()))
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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,
|
||||
|
||||
@@ -797,6 +797,12 @@ class MonitorRuleEngine:
|
||||
as_of=cn_today(),
|
||||
current=df,
|
||||
)
|
||||
if getattr(s, "execution_backend", "polars_expr") == "composite":
|
||||
# 叠加策略首版不支持实时监控: 各子策略需独立预热实时矩阵, 热路径成本为 N 倍,
|
||||
# 违反"实时热路径不得随历史数据量线性增长"约束。fail-closed 跳过本轮,
|
||||
# /cached 端点回退到盘后 strategy_cache.json 的批量结果。
|
||||
logger.debug("叠加策略 %s 暂不支持实时监控, 跳过", sid)
|
||||
return []
|
||||
if getattr(s, "execution_backend", "polars_expr") == "matrix_native":
|
||||
matrix = self._active_matrix_snapshots.get(at)
|
||||
if matrix is None:
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
"""叠加策略回测端到端集成测试 (M2 验证)。
|
||||
|
||||
用真实的内置 matrix_native 子策略(ma_golden_cross + macd_golden) + 合成行情 panel,
|
||||
完整跑通 StrategyBacktestService.run() 的 composite 分支:
|
||||
特征计划合并 → 超集矩阵加载 → 逐子策略信号 → 合并(退出投影) → 撮合 → 结果归因。
|
||||
|
||||
验证两种模式(position/full)和两种合并模式(union/intersect)的关键路径。
|
||||
合成数据用上涨趋势制造金叉信号。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date, timedelta
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import polars as pl
|
||||
import pytest
|
||||
|
||||
from app.backtest.engine import BacktestEngine
|
||||
from app.backtest.matrix import build_market_data_matrix
|
||||
from app.backtest.strategy import StrategyBacktestConfig, StrategyBacktestService
|
||||
from app.strategy.engine import StrategyEngine
|
||||
|
||||
|
||||
def _synthetic_panel() -> pl.DataFrame:
|
||||
"""构造上涨趋势的合成 panel: 前 50 天横盘, 后 50 天上涨, 制造金叉信号。"""
|
||||
np.random.seed(42)
|
||||
n_days = 100
|
||||
symbols = ["000001.SZ", "000002.SZ", "600000.SH"]
|
||||
rows = []
|
||||
for sym_idx, sym in enumerate(symbols):
|
||||
base = 10.0 + sym_idx * 5
|
||||
for t in range(n_days):
|
||||
d = date(2025, 1, 2) + timedelta(days=int(t * 1.5))
|
||||
if t < 50:
|
||||
close = base + np.random.uniform(-0.3, 0.3)
|
||||
else:
|
||||
close = base * (1 + (t - 50) * 0.015) + np.random.uniform(-0.3, 0.3)
|
||||
open_p = close + np.random.uniform(-0.2, 0.2)
|
||||
high = max(open_p, close) + np.random.uniform(0.1, 0.5)
|
||||
low = min(open_p, close) - np.random.uniform(0.1, 0.5)
|
||||
vol = float(np.random.uniform(1e6, 5e6))
|
||||
rows.append({
|
||||
"symbol": sym, "date": d, "open": open_p, "high": high,
|
||||
"low": low, "close": close, "volume": vol,
|
||||
"raw_close": close, "raw_high": high, "raw_low": low,
|
||||
})
|
||||
return pl.DataFrame(rows).sort(["symbol", "date"])
|
||||
|
||||
|
||||
class _RepoStub:
|
||||
def get_index_daily(self, *args, **kwargs) -> pl.DataFrame:
|
||||
return pl.DataFrame()
|
||||
|
||||
|
||||
def _make_service(panel: pl.DataFrame, builtin: Path, comp_dir: Path):
|
||||
"""构造 StrategyBacktestService, 用 monkeypatch 的数据加载。"""
|
||||
bt_engine = BacktestEngine(_RepoStub())
|
||||
|
||||
def _load_mdm(self, symbols, start, end, feature_plan, asset_type="stock", **kw):
|
||||
df = panel.filter((pl.col("date") >= start) & (pl.col("date") <= end))
|
||||
if symbols:
|
||||
df = df.filter(pl.col("symbol").is_in(symbols))
|
||||
cols = [c for c in sorted(set(feature_plan.base_columns)) if c in df.columns]
|
||||
return build_market_data_matrix(df.select(cols), field_columns=feature_plan.matrix_columns)
|
||||
|
||||
bt_engine.load_market_data_matrix_for_backtest = _load_mdm.__get__(bt_engine)
|
||||
strategy_engine = StrategyEngine(strategy_dirs=[builtin, comp_dir])
|
||||
return StrategyBacktestService(bt_engine, strategy_engine)
|
||||
|
||||
|
||||
def _write_composite(
|
||||
comp_dir: Path, sid: str, children: list, *, merge_mode="union", min_confirm=0
|
||||
):
|
||||
children_repr = ", ".join(
|
||||
f'{{"strategy_id": "{c}", "weight": {w}}}' for c, w in children
|
||||
)
|
||||
opts = '["union", "intersect"]'
|
||||
params_block = (
|
||||
f'{{"id": "merge_mode", "type": "select", '
|
||||
f'"options": {opts}, "default": "{merge_mode}"}}, '
|
||||
f'{{"id": "min_confirm", "type": "int", "default": {min_confirm}}}'
|
||||
)
|
||||
(comp_dir / f"{sid}.py").write_text(
|
||||
f'''META = {{"id": "{sid}", "name": "{sid}", "asset_types": ["stock"], "timeframes": ["1d"],
|
||||
"params": [{params_block}], "children": [{children_repr}]}}
|
||||
EXECUTION_BACKEND = "composite"
|
||||
''',
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def composite_setup(tmp_path):
|
||||
builtin = Path(__file__).resolve().parents[2] / "app" / "strategy" / "builtin"
|
||||
comp_dir = tmp_path / "composite"
|
||||
comp_dir.mkdir(parents=True)
|
||||
_write_composite(comp_dir, "custom_e2e_blend", [("ma_golden_cross", 0.5), ("macd_golden", 0.5)])
|
||||
panel = _synthetic_panel()
|
||||
svc = _make_service(panel, builtin, comp_dir)
|
||||
return svc, comp_dir
|
||||
|
||||
|
||||
def test_composite_backtest_position_mode(composite_setup):
|
||||
"""position 模式: composite 回测产出交易、净值曲线和归因。"""
|
||||
svc, _ = composite_setup
|
||||
cfg = StrategyBacktestConfig(
|
||||
strategy_id="custom_e2e_blend", symbols=None,
|
||||
start=date(2025, 2, 1), end=date(2025, 5, 1),
|
||||
mode="position", max_positions=5, holding_days=10,
|
||||
overrides={"basic_filter": {"enabled": False}},
|
||||
)
|
||||
result = svc.run(cfg)
|
||||
assert result.error is None, f"回测失败: {result.error}"
|
||||
assert len(result.trades) > 0, "应产出交易"
|
||||
assert len(result.equity_curve) > 0, "应有净值曲线"
|
||||
assert result.strategy_info is not None
|
||||
children = result.strategy_info.get("composite_children")
|
||||
assert children is not None and len(children) == 2
|
||||
assert {c["id"] for c in children} == {"ma_golden_cross", "macd_golden"}
|
||||
|
||||
|
||||
def test_composite_backtest_full_mode(composite_setup):
|
||||
"""full 模式(独立候选): composite 回测能跑通。"""
|
||||
svc, _ = composite_setup
|
||||
cfg = StrategyBacktestConfig(
|
||||
strategy_id="custom_e2e_blend", symbols=None,
|
||||
start=date(2025, 2, 1), end=date(2025, 5, 1),
|
||||
mode="full", holding_days=10,
|
||||
overrides={"basic_filter": {"enabled": False}},
|
||||
)
|
||||
result = svc.run(cfg)
|
||||
assert result.error is None, f"full 模式失败: {result.error}"
|
||||
assert len(result.trades) > 0
|
||||
|
||||
|
||||
def test_composite_backtest_intersect_min1_equals_union(composite_setup):
|
||||
"""intersect min_confirm=1 应等价于 union(都产出交易)。"""
|
||||
svc, comp_dir = composite_setup
|
||||
_write_composite(
|
||||
comp_dir, "custom_e2e_blend",
|
||||
[("ma_golden_cross", 0.5), ("macd_golden", 0.5)],
|
||||
merge_mode="intersect", min_confirm=1,
|
||||
)
|
||||
svc.strategy_engine.reload()
|
||||
cfg = StrategyBacktestConfig(
|
||||
strategy_id="custom_e2e_blend", symbols=None,
|
||||
start=date(2025, 2, 1), end=date(2025, 5, 1),
|
||||
mode="position", max_positions=5, holding_days=10,
|
||||
overrides={"basic_filter": {"enabled": False}},
|
||||
)
|
||||
result = svc.run(cfg)
|
||||
assert result.error is None, f"intersect 模式失败: {result.error}"
|
||||
assert len(result.trades) > 0, "min_confirm=1 应产出交易(等价 union)"
|
||||
|
||||
|
||||
def test_worker_strategy_dirs_includes_composite(tmp_path):
|
||||
"""回归: 回测 worker 子进程重建引擎时必须扫描 composite 目录。
|
||||
|
||||
缺陷历史: worker._strategy_dirs 曾遗漏 composite 目录, 导致回测子进程
|
||||
找不到 composite 策略(报 unknown strategy)。main.py 与 worker.py 必须一致。
|
||||
"""
|
||||
from app.backtest.worker import _strategy_dirs
|
||||
|
||||
dirs = _strategy_dirs(tmp_path)
|
||||
dir_names = [d.name for d in dirs]
|
||||
assert "builtin" in dir_names
|
||||
assert "composite" in dir_names, f"worker._strategy_dirs 缺 composite 目录: {dir_names}"
|
||||
assert "custom" in dir_names
|
||||
assert "ai" in dir_names
|
||||
@@ -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
|
||||
@@ -0,0 +1,545 @@
|
||||
"""叠加策略 (composite) 加载、引用校验与选股合并测试。
|
||||
|
||||
覆盖 CONTRIBUTING §9 矩阵中的「策略」与「回测」相关最低要求:
|
||||
- 加载解析正确, source 推断为 composite
|
||||
- 引用缺失 → 移除孤儿 composite, 不波及无辜策略(插件隔离)
|
||||
- 禁止嵌套 composite、asset_types 不一致、超过上限均 fail-closed
|
||||
- find_dependents 用于删除防护
|
||||
- 选股 union / intersect 合并, 标准化排名加权融合 score
|
||||
|
||||
子策略用 polars_expr 后端(返回 True)以便用轻量 DataFrame 验证合并逻辑,
|
||||
不依赖 matrix_native 的矩阵加载。回测矩阵路径在 M2 单独测试。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date
|
||||
|
||||
import numpy as np
|
||||
import polars as pl
|
||||
|
||||
from app.strategy.engine import StrategyDataContext, StrategyEngine
|
||||
|
||||
|
||||
def _filter_strategy_code(strategy_id: str, body: str = "return pl.lit(True)") -> str:
|
||||
"""生成一个 polars_expr 策略文件(filter 始终命中全部标的)。"""
|
||||
return f'''import polars as pl
|
||||
META = {{
|
||||
"id": "{strategy_id}",
|
||||
"name": "{strategy_id}",
|
||||
"asset_types": ["stock"],
|
||||
"timeframes": ["1d"],
|
||||
"scoring": {{"close": 1.0}},
|
||||
}}
|
||||
EXECUTION_BACKEND = "polars_expr"
|
||||
def filter(df, params):
|
||||
{body}
|
||||
'''
|
||||
|
||||
|
||||
def _composite_code(
|
||||
strategy_id: str,
|
||||
children: list[tuple[str, float]],
|
||||
*,
|
||||
name: str | None = None,
|
||||
asset_types: list[str] | None = None,
|
||||
merge_mode: str = "union",
|
||||
min_confirm: int = 0,
|
||||
) -> str:
|
||||
"""生成一个声明式 composite 策略文件。"""
|
||||
children_repr = ", ".join(
|
||||
f'{{"strategy_id": "{cid}", "weight": {w}}}' for cid, w in children
|
||||
)
|
||||
ats = asset_types or ["stock"]
|
||||
ats_repr = ", ".join(f'"{a}"' for a in ats)
|
||||
params = (
|
||||
f'{{"id": "merge_mode", "type": "select", '
|
||||
f'"options": ["union", "intersect"], "default": "{merge_mode}"}}, '
|
||||
f'{{"id": "min_confirm", "type": "int", "default": {min_confirm}}}'
|
||||
)
|
||||
return f'''META = {{
|
||||
"id": "{strategy_id}",
|
||||
"name": "{name or strategy_id}",
|
||||
"asset_types": [{ats_repr}],
|
||||
"timeframes": ["1d"],
|
||||
"params": [{params}],
|
||||
"children": [{children_repr}],
|
||||
}}
|
||||
EXECUTION_BACKEND = "composite"
|
||||
'''
|
||||
|
||||
|
||||
# ───────────────────────── 加载与引用校验 ─────────────────────────
|
||||
|
||||
|
||||
def test_composite_loads_and_infers_source(tmp_path):
|
||||
child_dir = tmp_path / "strategies" / "custom"
|
||||
comp_dir = tmp_path / "strategies" / "composite"
|
||||
child_dir.mkdir(parents=True)
|
||||
comp_dir.mkdir(parents=True)
|
||||
(child_dir / "child_a.py").write_text(_filter_strategy_code("child_a"), encoding="utf-8")
|
||||
(comp_dir / "custom_blend.py").write_text(
|
||||
_composite_code("custom_blend", [("child_a", 1.0)]), encoding="utf-8"
|
||||
)
|
||||
|
||||
engine = StrategyEngine(strategy_dirs=[child_dir, comp_dir])
|
||||
|
||||
assert engine.has("custom_blend")
|
||||
blend = engine.get("custom_blend")
|
||||
assert blend.execution_backend == "composite"
|
||||
assert blend.source == "composite"
|
||||
assert blend.composite is not None
|
||||
assert blend.composite.children[0].strategy_id == "child_a"
|
||||
assert engine.load_errors() == []
|
||||
|
||||
|
||||
def test_composite_missing_child_is_orphaned_without_blocking_others(tmp_path):
|
||||
"""引用不存在的子策略 → composite 被移除并记错, 但不影响其他正常策略。"""
|
||||
child_dir = tmp_path / "strategies" / "custom"
|
||||
comp_dir = tmp_path / "strategies" / "composite"
|
||||
child_dir.mkdir(parents=True)
|
||||
comp_dir.mkdir(parents=True)
|
||||
(child_dir / "real_child.py").write_text(_filter_strategy_code("real_child"), encoding="utf-8")
|
||||
# composite 引用了不存在的 ghost_child
|
||||
(comp_dir / "orphan_blend.py").write_text(
|
||||
_composite_code("orphan_blend", [("ghost_child", 1.0)]), encoding="utf-8"
|
||||
)
|
||||
# 另一个正常 composite 不受影响
|
||||
(comp_dir / "healthy_blend.py").write_text(
|
||||
_composite_code("healthy_blend", [("real_child", 1.0)]), encoding="utf-8"
|
||||
)
|
||||
|
||||
engine = StrategyEngine(strategy_dirs=[child_dir, comp_dir])
|
||||
|
||||
assert not engine.has("orphan_blend") # 孤儿被移除
|
||||
assert engine.has("real_child") # 子策略不受影响
|
||||
assert engine.has("healthy_blend") # 其他 composite 不受影响(隔离原则)
|
||||
errors = engine.load_errors()
|
||||
orphan_errors = [e for e in errors if "orphan_blend" in e["file"]]
|
||||
assert len(orphan_errors) == 1
|
||||
assert "ghost_child" in orphan_errors[0]["error"]
|
||||
|
||||
|
||||
def test_nested_composite_rejected(tmp_path):
|
||||
"""禁止 composite 嵌套 composite。"""
|
||||
child_dir = tmp_path / "strategies" / "custom"
|
||||
comp_dir = tmp_path / "strategies" / "composite"
|
||||
child_dir.mkdir(parents=True)
|
||||
comp_dir.mkdir(parents=True)
|
||||
(child_dir / "leaf.py").write_text(_filter_strategy_code("leaf"), encoding="utf-8")
|
||||
(comp_dir / "inner.py").write_text(
|
||||
_composite_code("inner", [("leaf", 1.0)]), encoding="utf-8"
|
||||
)
|
||||
(comp_dir / "outer.py").write_text(
|
||||
_composite_code("outer", [("inner", 1.0)]), encoding="utf-8"
|
||||
)
|
||||
|
||||
engine = StrategyEngine(strategy_dirs=[child_dir, comp_dir])
|
||||
|
||||
assert engine.has("inner") # 单层 composite 合法
|
||||
assert not engine.has("outer") # 嵌套被拒
|
||||
outer_errors = [e for e in engine.load_errors() if "outer" in e["file"]]
|
||||
assert len(outer_errors) == 1
|
||||
assert "嵌套" in outer_errors[0]["error"] or "nested" in outer_errors[0]["error"].lower()
|
||||
|
||||
|
||||
def test_composite_asset_type_mismatch_rejected(tmp_path):
|
||||
"""composite 与子策略 asset_types 不一致 → fail-closed。"""
|
||||
child_dir = tmp_path / "strategies" / "custom"
|
||||
comp_dir = tmp_path / "strategies" / "composite"
|
||||
child_dir.mkdir(parents=True)
|
||||
comp_dir.mkdir(parents=True)
|
||||
(child_dir / "etf_child.py").write_text(
|
||||
_filter_strategy_code("etf_child"), encoding="utf-8"
|
||||
)
|
||||
# 子策略是 stock(默认), composite 声明 etf
|
||||
(comp_dir / "mismatched.py").write_text(
|
||||
_composite_code("mismatched", [("etf_child", 1.0)], asset_types=["etf"]),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
engine = StrategyEngine(strategy_dirs=[child_dir, comp_dir])
|
||||
|
||||
assert not engine.has("mismatched")
|
||||
errors = [e for e in engine.load_errors() if "mismatched" in e["file"]]
|
||||
assert len(errors) == 1
|
||||
assert "asset_types" in errors[0]["error"]
|
||||
|
||||
|
||||
def test_composite_asset_type_subset_is_allowed(tmp_path):
|
||||
"""子策略支持的范围 ⊇ composite 声明 → 合法(子集关系)。
|
||||
|
||||
内置策略常声明 ['stock','etf'], 用户叠加时只关注 stock, 不应被拒绝。
|
||||
"""
|
||||
child_dir = tmp_path / "strategies" / "custom"
|
||||
comp_dir = tmp_path / "strategies" / "composite"
|
||||
child_dir.mkdir(parents=True)
|
||||
comp_dir.mkdir(parents=True)
|
||||
# 子策略支持 stock + etf
|
||||
multi_code = _filter_strategy_code("multi").replace(
|
||||
'"asset_types": ["stock"]', '"asset_types": ["stock", "etf"]'
|
||||
)
|
||||
(child_dir / "multi.py").write_text(multi_code, encoding="utf-8")
|
||||
# composite 只声明 stock(子集) → 合法
|
||||
(comp_dir / "subset_ok.py").write_text(
|
||||
_composite_code("subset_ok", [("multi", 1.0)], asset_types=["stock"]),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
engine = StrategyEngine(strategy_dirs=[child_dir, comp_dir])
|
||||
assert engine.has("subset_ok")
|
||||
assert engine.load_errors() == []
|
||||
|
||||
|
||||
def test_composite_exceeds_child_limit_rejected(tmp_path):
|
||||
"""子策略数量超过 MAX_COMPOSITE_CHILDREN → fail-closed。"""
|
||||
from app.strategy.engine import MAX_COMPOSITE_CHILDREN
|
||||
|
||||
child_dir = tmp_path / "strategies" / "custom"
|
||||
comp_dir = tmp_path / "strategies" / "composite"
|
||||
child_dir.mkdir(parents=True)
|
||||
comp_dir.mkdir(parents=True)
|
||||
for i in range(MAX_COMPOSITE_CHILDREN + 1):
|
||||
(child_dir / f"c{i}.py").write_text(_filter_strategy_code(f"c{i}"), encoding="utf-8")
|
||||
children = [(f"c{i}", 1.0) for i in range(MAX_COMPOSITE_CHILDREN + 1)]
|
||||
(comp_dir / "too_many.py").write_text(
|
||||
_composite_code("too_many", children), encoding="utf-8"
|
||||
)
|
||||
|
||||
engine = StrategyEngine(strategy_dirs=[child_dir, comp_dir])
|
||||
|
||||
assert not engine.has("too_many")
|
||||
errors = [e for e in engine.load_errors() if "too_many" in e["file"]]
|
||||
assert len(errors) == 1
|
||||
assert "limit" in errors[0]["error"] or "exceed" in errors[0]["error"]
|
||||
|
||||
|
||||
def test_composite_with_filter_fn_rejected(tmp_path):
|
||||
"""composite 策略声明了 filter 函数 → 加载失败。"""
|
||||
comp_dir = tmp_path / "strategies" / "composite"
|
||||
comp_dir.mkdir(parents=True)
|
||||
code = _composite_code("bad", []) + "def filter(df, params):\n return pl.lit(True)\n"
|
||||
(comp_dir / "bad.py").write_text(code, encoding="utf-8")
|
||||
|
||||
engine = StrategyEngine(strategy_dirs=[comp_dir])
|
||||
|
||||
assert not engine.has("bad")
|
||||
assert any("bad" in e["file"] for e in engine.load_errors())
|
||||
|
||||
|
||||
# ───────────────────────── find_dependents ─────────────────────────
|
||||
|
||||
|
||||
def test_find_dependents_locates_referencing_composites(tmp_path):
|
||||
child_dir = tmp_path / "strategies" / "custom"
|
||||
comp_dir = tmp_path / "strategies" / "composite"
|
||||
child_dir.mkdir(parents=True)
|
||||
comp_dir.mkdir(parents=True)
|
||||
(child_dir / "shared.py").write_text(_filter_strategy_code("shared"), encoding="utf-8")
|
||||
(child_dir / "other.py").write_text(_filter_strategy_code("other"), encoding="utf-8")
|
||||
(comp_dir / "blend_a.py").write_text(
|
||||
_composite_code("blend_a", [("shared", 0.5), ("other", 0.5)]), encoding="utf-8"
|
||||
)
|
||||
(comp_dir / "blend_b.py").write_text(
|
||||
_composite_code("blend_b", [("shared", 1.0)]), encoding="utf-8"
|
||||
)
|
||||
|
||||
engine = StrategyEngine(strategy_dirs=[child_dir, comp_dir])
|
||||
|
||||
assert sorted(engine.find_dependents("shared")) == ["blend_a", "blend_b"]
|
||||
assert engine.find_dependents("other") == ["blend_a"]
|
||||
assert engine.find_dependents("nonexistent") == []
|
||||
|
||||
|
||||
# ───────────────────────── 选股合并 ─────────────────────────
|
||||
|
||||
|
||||
def _stock_panel(symbols: list[str], scores: list[float]) -> pl.DataFrame:
|
||||
"""构造一个含 close 列(用于 scoring)的轻量 panel。"""
|
||||
return pl.DataFrame({
|
||||
"symbol": symbols,
|
||||
"date": [date(2026, 1, 2)] * len(symbols),
|
||||
"close": scores,
|
||||
})
|
||||
|
||||
|
||||
def test_composite_union_merge_combines_children(tmp_path):
|
||||
"""union 模式: 两个子策略命中不同标的 → 合并后包含全部。"""
|
||||
child_dir = tmp_path / "strategies" / "custom"
|
||||
comp_dir = tmp_path / "strategies" / "composite"
|
||||
child_dir.mkdir(parents=True)
|
||||
comp_dir.mkdir(parents=True)
|
||||
# child_a 只选 000001, child_b 只选 600000
|
||||
(child_dir / "child_a.py").write_text(
|
||||
_filter_strategy_code("child_a", body='return pl.col("symbol") == "000001.SZ"'),
|
||||
encoding="utf-8",
|
||||
)
|
||||
(child_dir / "child_b.py").write_text(
|
||||
_filter_strategy_code("child_b", body='return pl.col("symbol") == "600000.SH"'),
|
||||
encoding="utf-8",
|
||||
)
|
||||
(comp_dir / "union_blend.py").write_text(
|
||||
_composite_code("union_blend", [("child_a", 0.5), ("child_b", 0.5)]),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
engine = StrategyEngine(strategy_dirs=[child_dir, comp_dir])
|
||||
context = StrategyDataContext(
|
||||
asset_type="stock",
|
||||
timeframe="1d",
|
||||
as_of=date(2026, 1, 2),
|
||||
current=_stock_panel(["000001.SZ", "600000.SH"], [10.0, 20.0]),
|
||||
)
|
||||
|
||||
# 禁用基础过滤(测试 panel 无 amount 等列); composite 会把 basic_filter 透传给子策略
|
||||
result = engine.run(
|
||||
"union_blend", context, overrides={"basic_filter": {"enabled": False}}
|
||||
)
|
||||
|
||||
symbols = {row["symbol"] for row in result.rows}
|
||||
assert symbols == {"000001.SZ", "600000.SH"}
|
||||
assert result.total == 2
|
||||
# 合并 score 应该来自排名归一加权(两个子各命中一个, 均为各自第一 → norm=1)
|
||||
assert all(0 < result.scores[s] <= 100 for s in result.scores)
|
||||
|
||||
|
||||
def test_composite_intersect_requires_min_confirm(tmp_path):
|
||||
"""intersect 模式: 只有多个子策略共同命中才入选。"""
|
||||
child_dir = tmp_path / "strategies" / "custom"
|
||||
comp_dir = tmp_path / "strategies" / "composite"
|
||||
child_dir.mkdir(parents=True)
|
||||
comp_dir.mkdir(parents=True)
|
||||
# 两个子策略都选 000001(共振), 但 child_b 还选 600000(非共振)
|
||||
(child_dir / "child_a.py").write_text(
|
||||
_filter_strategy_code("child_a", body='return pl.col("symbol") == "000001.SZ"'),
|
||||
encoding="utf-8",
|
||||
)
|
||||
(child_dir / "child_b.py").write_text(
|
||||
_filter_strategy_code(
|
||||
"child_b",
|
||||
body='return pl.col("symbol").is_in(["000001.SZ", "600000.SH"])',
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
(comp_dir / "intersect_blend.py").write_text(
|
||||
_composite_code(
|
||||
"intersect_blend",
|
||||
[("child_a", 0.5), ("child_b", 0.5)],
|
||||
merge_mode="intersect",
|
||||
min_confirm=2,
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
engine = StrategyEngine(strategy_dirs=[child_dir, comp_dir])
|
||||
context = StrategyDataContext(
|
||||
asset_type="stock",
|
||||
timeframe="1d",
|
||||
as_of=date(2026, 1, 2),
|
||||
current=_stock_panel(["000001.SZ", "600000.SH"], [10.0, 20.0]),
|
||||
)
|
||||
|
||||
result = engine.run(
|
||||
"intersect_blend", context, overrides={"basic_filter": {"enabled": False}}
|
||||
)
|
||||
|
||||
# 只有 000001 同时被两个子策略命中
|
||||
symbols = {row["symbol"] for row in result.rows}
|
||||
assert symbols == {"000001.SZ"}
|
||||
|
||||
|
||||
def test_composite_weighted_score_ranking(tmp_path):
|
||||
"""权重影响最终排名: 高权重子策略的最优标的应排前。"""
|
||||
from app.strategy import composite as composite_mod
|
||||
from app.strategy.engine import StrategyResult
|
||||
|
||||
# 直接测合并器: 两个子策略, 命中相同标的但内部排名不同。
|
||||
as_of = date(2026, 1, 2)
|
||||
res_a = StrategyResult(
|
||||
as_of=as_of,
|
||||
strategy_id="a",
|
||||
scores={"X": 100.0, "Y": 50.0}, # X 优于 Y
|
||||
)
|
||||
res_b = StrategyResult(
|
||||
as_of=as_of,
|
||||
strategy_id="b",
|
||||
scores={"X": 10.0, "Y": 90.0}, # Y 优于 X
|
||||
)
|
||||
|
||||
# b 权重远大于 a → 合并后 Y 应得分更高
|
||||
merged = composite_mod.merge_results(
|
||||
[res_a, res_b],
|
||||
[0.1, 0.9],
|
||||
"union",
|
||||
0,
|
||||
as_of=as_of,
|
||||
strategy_id="blend",
|
||||
)
|
||||
# a 中 X rank=1(norm=1), Y rank=2(norm=0)
|
||||
# b 中 X rank=2(norm=0), Y rank=1(norm=1)
|
||||
# X = (0.1*1 + 0.9*0)/1.0 = 0.1; Y = (0.1*0 + 0.9*1)/1.0 = 0.9
|
||||
assert merged.scores["Y"] > merged.scores["X"]
|
||||
|
||||
|
||||
def test_composite_no_scores_uses_neutral(tmp_path):
|
||||
"""子策略无 score 时用中性分, 不报错也不污染。"""
|
||||
from app.strategy import composite as composite_mod
|
||||
from app.strategy.engine import StrategyResult
|
||||
|
||||
as_of = date(2026, 1, 2)
|
||||
res = StrategyResult(
|
||||
as_of=as_of,
|
||||
strategy_id="a",
|
||||
rows=[{"symbol": "X"}, {"symbol": "Y"}],
|
||||
scores={}, # 无 score
|
||||
)
|
||||
|
||||
merged = composite_mod.merge_results([res], [1.0], "union", 0, as_of=as_of, strategy_id="b")
|
||||
|
||||
assert set(merged.scores) == {"X", "Y"}
|
||||
assert all(abs(s - 50.0) < 0.01 for s in merged.scores.values()) # 中性分 0.5*100
|
||||
|
||||
|
||||
def test_composite_empty_children_returns_empty(tmp_path):
|
||||
"""空子结果列表 → 返回空 StrategyResult。"""
|
||||
from app.strategy import composite as composite_mod
|
||||
|
||||
as_of = date(2026, 1, 2)
|
||||
merged = composite_mod.merge_results(
|
||||
[], [], "union", 0, as_of=as_of, strategy_id="empty"
|
||||
)
|
||||
assert merged.total == 0
|
||||
assert merged.scores == {}
|
||||
|
||||
|
||||
# ───────────────────────── 回测合并: merge_signal_matrices ─────────────────────────
|
||||
|
||||
|
||||
def _make_sig(shape, *, entry, exit_, score=None):
|
||||
"""构造一个轻量 SignalMatrix(用 make_signal_matrix 保证 dtype/只读)。"""
|
||||
from app.backtest.matrix import make_signal_matrix
|
||||
|
||||
entry_arr = np.array(entry, dtype=np.uint8)
|
||||
exit_arr = np.array(exit_, dtype=np.uint8)
|
||||
score_arr = (
|
||||
np.array(score, dtype=np.float32)
|
||||
if score is not None
|
||||
else np.full(shape, 50.0, dtype=np.float32)
|
||||
)
|
||||
return make_signal_matrix(
|
||||
shape,
|
||||
entry=entry_arr,
|
||||
exit=exit_arr,
|
||||
score=score_arr,
|
||||
)
|
||||
|
||||
|
||||
def test_merge_signal_matrices_union_entry():
|
||||
"""union 模式: entry = OR(各子 entry)。"""
|
||||
from app.strategy import composite as composite_mod
|
||||
|
||||
shape = (3, 2)
|
||||
# child A 选中 asset 0; child B 选中 asset 1
|
||||
sig_a = _make_sig(shape, entry=[[1, 0], [0, 0], [0, 0]], exit_=[[0, 0], [0, 0], [0, 0]])
|
||||
sig_b = _make_sig(shape, entry=[[0, 1], [0, 0], [0, 0]], exit_=[[0, 0], [0, 0], [0, 0]])
|
||||
|
||||
merged = composite_mod.merge_signal_matrices(
|
||||
shape, [sig_a, sig_b], [("a", 0.5), ("b", 0.5)], "union", 0, max_hold=2
|
||||
)
|
||||
|
||||
assert merged.entry[0, 0] == 1 # A 选中
|
||||
assert merged.entry[0, 1] == 1 # B 选中
|
||||
assert merged.entry[1].sum() == 0 # 后续无新入场
|
||||
|
||||
|
||||
def test_merge_signal_matrices_intersect_entry():
|
||||
"""intersect 模式: 只有多个子策略同时命中才入选。"""
|
||||
from app.strategy import composite as composite_mod
|
||||
|
||||
shape = (2, 2)
|
||||
# asset 0 被两个子策略同时命中(共振); asset 1 只被 A 命中
|
||||
sig_a = _make_sig(shape, entry=[[1, 1], [0, 0]], exit_=[[0, 0], [0, 0]])
|
||||
sig_b = _make_sig(shape, entry=[[1, 0], [0, 0]], exit_=[[0, 0], [0, 0]])
|
||||
|
||||
merged = composite_mod.merge_signal_matrices(
|
||||
shape, [sig_a, sig_b], [("a", 0.5), ("b", 0.5)], "intersect", 2, max_hold=2
|
||||
)
|
||||
|
||||
assert merged.entry[0, 0] == 1 # 共振入选
|
||||
assert merged.entry[0, 1] == 0 # 非共振排除
|
||||
|
||||
|
||||
def test_merge_exit_projection_prevents_cross_close():
|
||||
"""退出投影: 子策略 B 的 exit 不会平掉子策略 A 选中的仓位。
|
||||
|
||||
场景: A 在 t=0 买入 asset 0, max_hold=3 → A 的持仓窗口 t∈[0,2]。
|
||||
B 没买 asset 0, 但 B 在 t=1 对 asset 0 标了 exit(模拟无关退出信号)。
|
||||
期望: 合并 exit 在 t=1 的 asset 0 应为 0(B 未持仓, 其 exit 被投影清零)。
|
||||
"""
|
||||
from app.strategy import composite as composite_mod
|
||||
|
||||
shape = (3, 1)
|
||||
sig_a = _make_sig(shape, entry=[[1], [0], [0]], exit_=[[0], [0], [0]])
|
||||
# B 没买 asset 0, 但在 t=1 标了 exit
|
||||
sig_b = _make_sig(shape, entry=[[0], [0], [0]], exit_=[[0], [1], [0]])
|
||||
|
||||
merged = composite_mod.merge_signal_matrices(
|
||||
shape, [sig_a, sig_b], [("a", 1.0), ("b", 1.0)], "union", 0, max_hold=3
|
||||
)
|
||||
|
||||
# 关键断言: B 的 exit(t=1) 被投影清零, 因为 B 在 asset 0 没有持仓窗口
|
||||
assert merged.exit[1, 0] == 0, "B 的退出信号不应平掉 A 的仓位"
|
||||
|
||||
|
||||
def test_merge_exit_respects_child_own_exit():
|
||||
"""退出投影: 子策略自己的 exit 在自己持仓窗口内有效。
|
||||
|
||||
场景: A 在 t=0 买入, 在 t=2 标 exit; max_hold=3。
|
||||
期望: 合并 exit 在 t=2 为 1(A 自己的退出在其持仓窗口内, 生效)。
|
||||
"""
|
||||
from app.strategy import composite as composite_mod
|
||||
|
||||
shape = (4, 1)
|
||||
sig_a = _make_sig(shape, entry=[[1], [0], [0], [0]], exit_=[[0], [0], [1], [0]])
|
||||
|
||||
merged = composite_mod.merge_signal_matrices(
|
||||
shape, [sig_a], [("a", 1.0)], "union", 0, max_hold=3
|
||||
)
|
||||
|
||||
assert merged.exit[2, 0] == 1 # A 自己的 exit 在窗口内, 生效
|
||||
|
||||
|
||||
def test_merge_exit_max_hold_caps_window():
|
||||
"""退出投影: 持仓窗口由 max_hold 封顶; 超出窗口后 exit 不生效。
|
||||
|
||||
场景: A 在 t=0 买入, max_hold=2 → 窗口 t∈[0,1]。A 在 t=3 标 exit(超出窗口)。
|
||||
期望: 合并 exit 在 t=3 为 0(超出持仓窗口, exit 无效)。
|
||||
"""
|
||||
from app.strategy import composite as composite_mod
|
||||
|
||||
shape = (5, 1)
|
||||
sig_a = _make_sig(shape, entry=[[1], [0], [0], [0], [0]], exit_=[[0], [0], [0], [1], [0]])
|
||||
|
||||
merged = composite_mod.merge_signal_matrices(
|
||||
shape, [sig_a], [("a", 1.0)], "union", 0, max_hold=2
|
||||
)
|
||||
|
||||
assert merged.exit[1, 0] == 0 # 窗口内 A 无 exit
|
||||
assert merged.exit[3, 0] == 0 # 超出窗口, exit 无效
|
||||
|
||||
|
||||
def test_merge_entry_signal_code_records_source():
|
||||
"""合并后 entry_signal_code 标记来源子策略(归因用)。"""
|
||||
from app.strategy import composite as composite_mod
|
||||
|
||||
shape = (1, 2)
|
||||
sig_a = _make_sig(shape, entry=[[1, 0]], exit_=[[0, 0]])
|
||||
sig_b = _make_sig(shape, entry=[[0, 1]], exit_=[[0, 0]])
|
||||
|
||||
merged = composite_mod.merge_signal_matrices(
|
||||
shape, [sig_a, sig_b], [("child_a", 1.0), ("child_b", 1.0)], "union", 0, max_hold=1
|
||||
)
|
||||
|
||||
# asset 0 来自 child A (code=0), asset 1 来自 child B (code=1)
|
||||
assert merged.entry_signal_code[0, 0] == 0
|
||||
assert merged.entry_signal_code[0, 1] == 1
|
||||
assert merged.entry_signal_ids == ("composite:child_a", "composite:child_b")
|
||||
@@ -0,0 +1,230 @@
|
||||
"""叠加策略 API 与删除防护测试 (M2)。
|
||||
|
||||
覆盖:
|
||||
- _render_composite_code 渲染声明式 .py 正确
|
||||
- _save_composite_strategy 创建/更新/校验(前缀/嵌套/子策略不存在/重复创建)
|
||||
- _strategy_detail 回显 composite_children
|
||||
- delete_strategy 删除被引用子策略时 409 fail-closed
|
||||
|
||||
复用 test_strategy_code_save.py 的 SimpleNamespace 构造模式。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
|
||||
from app.api.strategy import (
|
||||
CompositeChildItem,
|
||||
StrategyCompositeSaveRequest,
|
||||
_render_composite_code,
|
||||
_save_composite_strategy,
|
||||
_strategy_detail,
|
||||
)
|
||||
from app.strategy.engine import StrategyEngine
|
||||
|
||||
|
||||
def _filter_code(strategy_id: str) -> str:
|
||||
meta = (
|
||||
f'{{"id": "{strategy_id}", "name": "{strategy_id}", '
|
||||
'"asset_types": ["stock"], "timeframes": ["1d"]}'
|
||||
)
|
||||
return f'''import polars as pl
|
||||
META = {meta}
|
||||
EXECUTION_BACKEND = "polars_expr"
|
||||
def filter(df, params):
|
||||
return pl.lit(True)
|
||||
'''
|
||||
|
||||
|
||||
def _setup_engine(tmp_path):
|
||||
"""构造含 custom + composite 目录的 engine 和假 request。"""
|
||||
custom_dir = tmp_path / "strategies" / "custom"
|
||||
comp_dir = tmp_path / "strategies" / "composite"
|
||||
custom_dir.mkdir(parents=True)
|
||||
comp_dir.mkdir(parents=True)
|
||||
engine = StrategyEngine(strategy_dirs=[custom_dir, comp_dir])
|
||||
repo = SimpleNamespace(store=SimpleNamespace(data_dir=tmp_path))
|
||||
request = SimpleNamespace(
|
||||
app=SimpleNamespace(state=SimpleNamespace(repo=repo, strategy_engine=engine))
|
||||
)
|
||||
return engine, request, custom_dir, comp_dir
|
||||
|
||||
|
||||
def _composite_req(sid, children, **kw):
|
||||
return StrategyCompositeSaveRequest(
|
||||
strategy_id=sid,
|
||||
name=kw.get("name", sid),
|
||||
description=kw.get("description", ""),
|
||||
children=[CompositeChildItem(strategy_id=c, weight=w) for c, w in children],
|
||||
merge_mode=kw.get("merge_mode", "union"),
|
||||
min_confirm=kw.get("min_confirm", 0),
|
||||
mode=kw.get("mode", "create"),
|
||||
)
|
||||
|
||||
|
||||
# ───────────────────────── 渲染 ─────────────────────────
|
||||
|
||||
|
||||
def test_render_composite_code_contains_children_and_backend():
|
||||
code = _render_composite_code(
|
||||
"composite_blend", "我的叠加", "描述",
|
||||
[{"strategy_id": "child_a", "weight": 0.4}, {"strategy_id": "child_b", "weight": 0.6}],
|
||||
"union", 0,
|
||||
)
|
||||
assert 'EXECUTION_BACKEND = "composite"' in code
|
||||
assert "composite_blend" in code
|
||||
assert "child_a" in code
|
||||
assert "child_b" in code
|
||||
assert "0.4" in code
|
||||
|
||||
|
||||
# ───────────────────────── 保存 ─────────────────────────
|
||||
|
||||
|
||||
def test_save_composite_creates_and_loads(tmp_path):
|
||||
engine, request, custom_dir, _ = _setup_engine(tmp_path)
|
||||
(custom_dir / "child_a.py").write_text(_filter_code("child_a"), encoding="utf-8")
|
||||
engine.reload()
|
||||
|
||||
result = _save_composite_strategy(
|
||||
_composite_req("composite_blend", [("child_a", 1.0)]), request
|
||||
)
|
||||
assert result["ok"] is True
|
||||
assert result["source"] == "composite"
|
||||
assert engine.has("composite_blend")
|
||||
blend = engine.get("composite_blend")
|
||||
assert blend.execution_backend == "composite"
|
||||
assert blend.composite.children[0].strategy_id == "child_a"
|
||||
|
||||
|
||||
def test_save_composite_rejects_missing_composite_prefix(tmp_path):
|
||||
engine, request, custom_dir, _ = _setup_engine(tmp_path)
|
||||
(custom_dir / "child_a.py").write_text(_filter_code("child_a"), encoding="utf-8")
|
||||
engine.reload()
|
||||
|
||||
with pytest.raises(ValueError, match="composite_"):
|
||||
_save_composite_strategy(
|
||||
_composite_req("bad_prefix", [("child_a", 1.0)]), request
|
||||
)
|
||||
|
||||
|
||||
def test_save_composite_rejects_missing_child(tmp_path):
|
||||
_engine, request, _, _ = _setup_engine(tmp_path)
|
||||
|
||||
with pytest.raises(ValueError, match="不存在"):
|
||||
_save_composite_strategy(
|
||||
_composite_req("composite_blend", [("ghost", 1.0)]), request
|
||||
)
|
||||
|
||||
|
||||
def test_save_composite_rejects_nested_composite(tmp_path):
|
||||
engine, request, custom_dir, _comp_dir = _setup_engine(tmp_path)
|
||||
(custom_dir / "leaf.py").write_text(_filter_code("leaf"), encoding="utf-8")
|
||||
engine.reload()
|
||||
# 先创建一个合法 composite
|
||||
_save_composite_strategy(
|
||||
_composite_req("composite_inner", [("leaf", 1.0)]), request
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="嵌套"):
|
||||
_save_composite_strategy(
|
||||
_composite_req("composite_outer", [("composite_inner", 1.0)]), request
|
||||
)
|
||||
|
||||
|
||||
def test_save_composite_update_mode(tmp_path):
|
||||
engine, request, custom_dir, _ = _setup_engine(tmp_path)
|
||||
(custom_dir / "child_a.py").write_text(_filter_code("child_a"), encoding="utf-8")
|
||||
(custom_dir / "child_b.py").write_text(_filter_code("child_b"), encoding="utf-8")
|
||||
engine.reload()
|
||||
_save_composite_strategy(
|
||||
_composite_req("composite_blend", [("child_a", 1.0)]), request
|
||||
)
|
||||
|
||||
# 更新: 改子策略
|
||||
result = _save_composite_strategy(
|
||||
_composite_req("composite_blend", [("child_a", 0.5), ("child_b", 0.5)], mode="update"),
|
||||
request,
|
||||
)
|
||||
assert result["ok"]
|
||||
blend = engine.get("composite_blend")
|
||||
assert {c.strategy_id for c in blend.composite.children} == {"child_a", "child_b"}
|
||||
|
||||
|
||||
def test_save_composite_update_rejects_non_composite(tmp_path):
|
||||
engine, request, custom_dir, _ = _setup_engine(tmp_path)
|
||||
# 一个普通(polars_expr)策略, 用 composite_ 前缀以通过 id 校验进入后续检查
|
||||
(custom_dir / "composite_plain.py").write_text(_filter_code("composite_plain"), encoding="utf-8")
|
||||
engine.reload()
|
||||
|
||||
with pytest.raises(ValueError, match="不是叠加策略"):
|
||||
_save_composite_strategy(
|
||||
_composite_req("composite_plain", [], mode="update"), request
|
||||
)
|
||||
|
||||
|
||||
# ───────────────────────── _strategy_detail ─────────────────────────
|
||||
|
||||
|
||||
def test_strategy_detail_shows_composite_children(tmp_path):
|
||||
engine, request, custom_dir, _ = _setup_engine(tmp_path)
|
||||
(custom_dir / "child_a.py").write_text(_filter_code("child_a"), encoding="utf-8")
|
||||
(custom_dir / "child_b.py").write_text(_filter_code("child_b"), encoding="utf-8")
|
||||
engine.reload()
|
||||
_save_composite_strategy(
|
||||
_composite_req("composite_blend", [("child_a", 0.4), ("child_b", 0.6)]), request
|
||||
)
|
||||
|
||||
detail = _strategy_detail(engine.get("composite_blend"))
|
||||
assert detail["execution_backend"] == "composite"
|
||||
assert detail["composite_children"] is not None
|
||||
children_map = {c["id"]: c["weight"] for c in detail["composite_children"]}
|
||||
assert children_map == {"child_a": 0.4, "child_b": 0.6}
|
||||
|
||||
|
||||
def test_strategy_detail_non_composite_has_null_children(tmp_path):
|
||||
engine, _request, custom_dir, _ = _setup_engine(tmp_path)
|
||||
(custom_dir / "child_a.py").write_text(_filter_code("child_a"), encoding="utf-8")
|
||||
engine.reload()
|
||||
|
||||
detail = _strategy_detail(engine.get("child_a"))
|
||||
assert detail["composite_children"] is None
|
||||
|
||||
|
||||
# ───────────────────────── 删除防护 ─────────────────────────
|
||||
|
||||
|
||||
def test_delete_referenced_child_blocked(tmp_path):
|
||||
"""删除被 composite 引用的子策略 → 409 fail-closed。"""
|
||||
engine, request, custom_dir, _ = _setup_engine(tmp_path)
|
||||
(custom_dir / "child_a.py").write_text(_filter_code("child_a"), encoding="utf-8")
|
||||
engine.reload()
|
||||
_save_composite_strategy(
|
||||
_composite_req("composite_blend", [("child_a", 1.0)]), request
|
||||
)
|
||||
|
||||
# 直接调端点函数(delete_strategy 需要 request)
|
||||
from app.api.strategy import delete_strategy
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
delete_strategy("child_a", request)
|
||||
assert exc_info.value.status_code == 409
|
||||
assert "composite_blend" in exc_info.value.detail
|
||||
|
||||
|
||||
def test_delete_composite_itself_succeeds(tmp_path):
|
||||
"""删除 composite 本身(不被引用)应成功。"""
|
||||
engine, request, custom_dir, _ = _setup_engine(tmp_path)
|
||||
(custom_dir / "child_a.py").write_text(_filter_code("child_a"), encoding="utf-8")
|
||||
engine.reload()
|
||||
_save_composite_strategy(
|
||||
_composite_req("composite_blend", [("child_a", 1.0)]), request
|
||||
)
|
||||
|
||||
from app.api.strategy import delete_strategy
|
||||
|
||||
result = delete_strategy("composite_blend", request)
|
||||
assert result["ok"] is True
|
||||
assert not engine.has("composite_blend")
|
||||
@@ -5,6 +5,7 @@ import { Wifi, Play, Loader2, X, Check, Crown } from 'lucide-react'
|
||||
import { api, type EndpointItem } from '@/lib/api'
|
||||
import { QK } from '@/lib/queryKeys'
|
||||
import { EXPERT_RANK, tierRank } from '@/lib/capability-labels'
|
||||
import { useDialogBackdrop } from '@/lib/useDialogBackdrop'
|
||||
|
||||
interface EpResult {
|
||||
ok: boolean
|
||||
@@ -18,6 +19,7 @@ interface EpResult {
|
||||
|
||||
export function EndpointTestDialog({ hasKey, tierLabel, currentEndpoint, onClose }: { hasKey: boolean; tierLabel: string; currentEndpoint: string; onClose: () => void }) {
|
||||
const qc = useQueryClient()
|
||||
const backdrop = useDialogBackdrop(onClose)
|
||||
const [results, setResults] = useState<Record<string, EpResult | null>>({})
|
||||
const [testing, setTesting] = useState<Record<string, boolean>>({})
|
||||
const [switching, setSwitching] = useState<string | null>(null)
|
||||
@@ -78,7 +80,7 @@ export function EndpointTestDialog({ hasKey, tierLabel, currentEndpoint, onClose
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="absolute inset-0 bg-black/60 backdrop-blur-sm"
|
||||
onClick={onClose}
|
||||
{...backdrop}
|
||||
/>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.95, y: 12 }}
|
||||
|
||||
@@ -23,6 +23,7 @@ import { useQuery } from '@tanstack/react-query'
|
||||
import { QK } from '@/lib/queryKeys'
|
||||
import type { ColumnConfig, ColumnGroup, ExtColumnDisplayConfig, CandleColumnConfig, IntradayColumnConfig } from '@/lib/list-columns'
|
||||
import { resolveCandleConfig, resolveIntradayConfig } from '@/lib/list-columns'
|
||||
import { useDialogBackdrop } from '@/lib/useDialogBackdrop'
|
||||
|
||||
interface ListColumnCustomizerProps {
|
||||
columns: ColumnConfig[]
|
||||
@@ -142,6 +143,7 @@ export function ListColumnCustomizer({
|
||||
enabled: open && showExtColumns,
|
||||
staleTime: 60_000,
|
||||
})
|
||||
const backdrop = useDialogBackdrop(onClose)
|
||||
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [expandedGroups, setExpandedGroups] = useState<Set<string>>(new Set())
|
||||
@@ -661,7 +663,7 @@ export function ListColumnCustomizer({
|
||||
initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
className="absolute inset-0 bg-black/50 backdrop-blur-sm"
|
||||
onClick={onClose}
|
||||
{...backdrop}
|
||||
/>
|
||||
<motion.div
|
||||
initial={{ x: '100%' }} animate={{ x: 0 }} exit={{ x: '100%' }}
|
||||
|
||||
@@ -48,6 +48,10 @@ export function Modal({
|
||||
closeOnBackdrop = true,
|
||||
}: ModalProps) {
|
||||
const panelRef = useRef<HTMLDivElement>(null)
|
||||
// 记录鼠标按下时是否落在遮罩(而非面板)上。
|
||||
// 仅当 mousedown 和 mouseup 都在遮罩时才视为"点击遮罩关闭",
|
||||
// 避免在面板内拖选文本时鼠标移出面板边缘导致误关 (拖拽穿透)。
|
||||
const mouseDownOnBackdrop = useRef(false)
|
||||
// onClose 存 ref: 焦点陷阱/ESC effect 只在挂载时装一次。否则父级每次重渲染 (或未 memo 的
|
||||
// onClose) 都让 effect 重跑, requestAnimationFrame(focusFirst) 会在每次输入后把焦点抢回
|
||||
// 面板首个元素, 导致对话框内文本框无法输入。
|
||||
@@ -118,7 +122,14 @@ export function Modal({
|
||||
return (
|
||||
<div
|
||||
className={overlayClassName}
|
||||
onClick={closeOnBackdrop ? (e) => { if (e.target === e.currentTarget) onClose() } : undefined}
|
||||
onMouseDown={(e) => {
|
||||
// 仅记录"按下时确实在遮罩上"; 在面板内按下时记 false。
|
||||
mouseDownOnBackdrop.current = e.target === e.currentTarget
|
||||
}}
|
||||
onClick={closeOnBackdrop ? (e) => {
|
||||
// 只有按下和松开都在遮罩上才关闭, 避免拖选文本误关。
|
||||
if (mouseDownOnBackdrop.current && e.target === e.currentTarget) onClose()
|
||||
} : undefined}
|
||||
>
|
||||
<div
|
||||
ref={panelRef}
|
||||
|
||||
@@ -10,6 +10,7 @@ import { DatePicker } from '@/components/DatePicker'
|
||||
import { RuleEditor } from '@/components/monitor/RuleEditor'
|
||||
import { usePreferences, useQuoteStatus } from '@/lib/useSharedQueries'
|
||||
import { setFocusSymbol, clearFocusSymbol } from '@/lib/useQuoteStream'
|
||||
import { useDialogBackdrop } from '@/lib/useDialogBackdrop'
|
||||
|
||||
interface Props {
|
||||
symbol: string | null
|
||||
@@ -45,6 +46,7 @@ export function StockPreviewDialog({ symbol, name, onClose, triggerInfo }: Props
|
||||
const [dateRange, setDateRange] = useState(getDefaultRange)
|
||||
const [showMonitorEditor, setShowMonitorEditor] = useState(false)
|
||||
const qc = useQueryClient()
|
||||
const backdrop = useDialogBackdrop(onClose)
|
||||
|
||||
const watchlist = useQuery({
|
||||
queryKey: QK.watchlist,
|
||||
@@ -109,7 +111,7 @@ export function StockPreviewDialog({ symbol, name, onClose, triggerInfo }: Props
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
className="absolute inset-0 bg-black/60 backdrop-blur-sm"
|
||||
onClick={onClose}
|
||||
{...backdrop}
|
||||
/>
|
||||
|
||||
{/* 弹窗主体 */}
|
||||
|
||||
@@ -30,6 +30,7 @@ import { cn } from '@/lib/cn'
|
||||
import type { DimensionGroup, QuoteMap } from '@/lib/analysis-adapter'
|
||||
import { computeQuoteMetrics } from '@/lib/analysis-adapter'
|
||||
import { fmtPct, priceColorClass } from '@/lib/format'
|
||||
import { useDialogBackdrop } from '@/lib/useDialogBackdrop'
|
||||
|
||||
// ===== 配置类型 =====
|
||||
|
||||
@@ -62,6 +63,7 @@ export function AnalysisConfigDialog({
|
||||
showHierarchyLevel?: boolean
|
||||
}) {
|
||||
const [draft, setDraft] = useState<AnalysisFieldConfig>(currentConfig)
|
||||
const backdrop = useDialogBackdrop(onClose)
|
||||
const { data: extList } = useQuery({
|
||||
queryKey: QK.extData,
|
||||
queryFn: api.extDataList,
|
||||
@@ -84,7 +86,7 @@ export function AnalysisConfigDialog({
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50" onClick={onClose}>
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50" {...backdrop}>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.95 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { AnimatePresence, motion } from 'framer-motion'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { api, type EnrichedField } from '@/lib/api'
|
||||
import { QK } from '@/lib/queryKeys'
|
||||
import { useDialogBackdrop } from '@/lib/useDialogBackdrop'
|
||||
|
||||
const TABLE_TITLES: Record<string, string> = {
|
||||
instruments: '个股维表',
|
||||
@@ -35,6 +36,7 @@ function categorize(name: string): string {
|
||||
|
||||
export function EnrichedSchemaModal({ table, onClose }: { table: string | null; onClose: () => void }) {
|
||||
const open = !!table
|
||||
const backdrop = useDialogBackdrop(onClose)
|
||||
const schema = useQuery({
|
||||
queryKey: QK.tableSchema(table!),
|
||||
queryFn: () => api.enrichedSchema(table!),
|
||||
@@ -63,7 +65,7 @@ export function EnrichedSchemaModal({ table, onClose }: { table: string | null;
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
>
|
||||
<div className="absolute inset-0 bg-black/40" onClick={onClose} />
|
||||
<div className="absolute inset-0 bg-black/40" {...backdrop} />
|
||||
<motion.div
|
||||
className="relative w-full max-w-xl max-h-[70vh] rounded-card border border-border bg-surface shadow-xl overflow-hidden mx-4"
|
||||
initial={{ opacity: 0, scale: 0.95, y: 8 }}
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { motion } from 'framer-motion'
|
||||
import { X } from 'lucide-react'
|
||||
import { useDialogBackdrop } from '@/lib/useDialogBackdrop'
|
||||
|
||||
export function SettingsModal({ title, onClose, children }: { title: string; onClose: () => void; children: React.ReactNode }) {
|
||||
const backdrop = useDialogBackdrop(onClose)
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||
<div className="absolute inset-0 bg-black/60 backdrop-blur-sm" onClick={onClose} />
|
||||
<div className="absolute inset-0 bg-black/60 backdrop-blur-sm" {...backdrop} />
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.95, y: 12 }}
|
||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
} from 'lucide-react'
|
||||
import { api, type ExtDataDetectUrlResult, type ExtDataField } from '@/lib/api'
|
||||
import { QK } from '@/lib/queryKeys'
|
||||
import { useDialogBackdrop } from '@/lib/useDialogBackdrop'
|
||||
|
||||
type SourceMode = 'url' | 'file' | 'manual'
|
||||
|
||||
@@ -26,6 +27,7 @@ type MappingChoice = {
|
||||
|
||||
export function CreateExtDialog({ onClose }: { onClose: () => void }) {
|
||||
const qc = useQueryClient()
|
||||
const backdrop = useDialogBackdrop(onClose)
|
||||
const [sourceMode, setSourceMode] = useState<SourceMode>('url')
|
||||
const [id, setId] = useState('')
|
||||
const [label, setLabel] = useState('')
|
||||
@@ -308,7 +310,7 @@ export function CreateExtDialog({ onClose }: { onClose: () => void }) {
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||
<div className="absolute inset-0 bg-black/60 backdrop-blur-sm" onClick={onClose} />
|
||||
<div className="absolute inset-0 bg-black/60 backdrop-blur-sm" {...backdrop} />
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.95, y: 12 }}
|
||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||
|
||||
@@ -4,9 +4,11 @@ import { motion } from 'framer-motion'
|
||||
import { X, Loader2, Upload } from 'lucide-react'
|
||||
import { api, type ExtDataConfig, type ExtDataField } from '@/lib/api'
|
||||
import { QK } from '@/lib/queryKeys'
|
||||
import { useDialogBackdrop } from '@/lib/useDialogBackdrop'
|
||||
|
||||
export function EditExtDialog({ config, onClose }: { config: ExtDataConfig; onClose: () => void }) {
|
||||
const qc = useQueryClient()
|
||||
const backdrop = useDialogBackdrop(onClose)
|
||||
const [label, setLabel] = useState(config.label)
|
||||
const [description, setDescription] = useState(config.description ?? '')
|
||||
const [fields, setFields] = useState<ExtDataField[]>([...config.fields])
|
||||
@@ -79,7 +81,7 @@ export function EditExtDialog({ config, onClose }: { config: ExtDataConfig; onCl
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||
<div className="absolute inset-0 bg-black/60 backdrop-blur-sm" onClick={onClose} />
|
||||
<div className="absolute inset-0 bg-black/60 backdrop-blur-sm" {...backdrop} />
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.95, y: 12 }}
|
||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
type ActiveTask, type HistoryReport,
|
||||
minimizeDialog, closeDialog, startAnalysis,
|
||||
} from '@/lib/aiReportStore'
|
||||
import { useDialogBackdrop } from '@/lib/useDialogBackdrop'
|
||||
|
||||
interface Props {
|
||||
/** 当前展示的任务;活跃任务或历史报告 */
|
||||
@@ -81,6 +82,8 @@ export function AiAnalysisDialog({ task, mode, minimized }: Props) {
|
||||
setTimeout(() => setCopied(false), 2000)
|
||||
}
|
||||
|
||||
const backdrop = useDialogBackdrop(closeDialog, () => !isWorking)
|
||||
|
||||
if (!open) return null
|
||||
|
||||
const error = task && 'error' in task ? task.error : ''
|
||||
@@ -90,7 +93,7 @@ export function AiAnalysisDialog({ task, mode, minimized }: Props) {
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }}
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm p-4"
|
||||
onClick={e => { if (e.target === e.currentTarget && !isWorking) closeDialog() }}
|
||||
{...backdrop}
|
||||
>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.96, y: 12 }} animate={{ opacity: 1, scale: 1, y: 0 }} exit={{ opacity: 0, scale: 0.96, y: 12 }}
|
||||
|
||||
@@ -36,6 +36,7 @@ const STRATEGY_SOURCE_META = {
|
||||
builtin: { label: '内置', className: 'border-accent/25 bg-accent/10 text-accent' },
|
||||
custom: { label: '自定义', className: 'border-emerald-400/25 bg-emerald-400/10 text-emerald-400' },
|
||||
ai: { label: 'AI', className: 'border-amber-400/25 bg-amber-400/10 text-amber-400' },
|
||||
composite: { label: '叠加', className: 'border-teal-500/25 bg-teal-500/10 text-teal-400' },
|
||||
} as const
|
||||
|
||||
const emptyRule = (preset?: Partial<MonitorRule>): MonitorRule => ({
|
||||
@@ -94,7 +95,7 @@ export function RuleEditor({ rule, preset, simple, onClose, onSaved }: Props) {
|
||||
const [error, setError] = useState('')
|
||||
const [symbolQuery, setSymbolQuery] = useState('')
|
||||
const [strategyQuery, setStrategyQuery] = useState('')
|
||||
const [strategyCategory, setStrategyCategory] = useState<'all' | 'builtin' | 'custom' | 'ai'>('all')
|
||||
const [strategyCategory, setStrategyCategory] = useState<'all' | 'builtin' | 'custom' | 'ai' | 'composite'>('all')
|
||||
// 标的搜索资产类型: ETF 一并搜股票; 指数只搜指数; 否则只搜股票。
|
||||
const symbolAssetTypes = assetType === 'etf' ? 'stock,etf' : assetType === 'index' ? 'index' : 'stock'
|
||||
const symbolSearch = useQuery({
|
||||
@@ -212,6 +213,7 @@ export function RuleEditor({ rule, preset, simple, onClose, onSaved }: Props) {
|
||||
{ key: 'builtin' as const, label: '内置', count: strategyPresets.filter(strategy => strategy.source === 'builtin').length },
|
||||
{ key: 'custom' as const, label: '自定义', count: strategyPresets.filter(strategy => strategy.source === 'custom').length },
|
||||
{ key: 'ai' as const, label: 'AI', count: strategyPresets.filter(strategy => strategy.source === 'ai').length },
|
||||
{ key: 'composite' as const, label: '叠加', count: strategyPresets.filter(strategy => strategy.source === 'composite').length },
|
||||
]
|
||||
|
||||
const onSignalPickerChange = (next: string[]) => {
|
||||
|
||||
@@ -0,0 +1,351 @@
|
||||
import { useState, useMemo, useEffect, useRef } from 'react'
|
||||
import { motion, AnimatePresence } from 'framer-motion'
|
||||
import { X, Layers, Plus, Loader2, Search } from 'lucide-react'
|
||||
import { api, type ScreenerStrategy } from '@/lib/api'
|
||||
|
||||
interface Props {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
onSavedId?: (id: string) => void | Promise<void>
|
||||
/** 编辑模式: 传入已有 composite 策略 id 时为 update, 否则 create */
|
||||
editStrategyId?: string | null
|
||||
}
|
||||
|
||||
interface ChildItem {
|
||||
strategy_id: string
|
||||
weight: number
|
||||
}
|
||||
|
||||
const SRC_MAP: Record<string, string> = { builtin: '内置', custom: '自定义', ai: 'AI' }
|
||||
const BADGE_CLS: Record<string, string> = {
|
||||
builtin: 'bg-accent/10 text-accent border-accent/20',
|
||||
ai: 'bg-purple-500/10 text-purple-400 border-purple-500/20',
|
||||
custom: 'bg-amber-400/10 text-amber-400 border-amber-400/30',
|
||||
}
|
||||
|
||||
export function CompositeStrategyDialog({ open, onClose, onSavedId, editStrategyId }: Props) {
|
||||
const isEdit = !!editStrategyId
|
||||
const [name, setName] = useState('')
|
||||
const [description, setDescription] = useState('')
|
||||
const [strategyId, setStrategyId] = useState('')
|
||||
const [children, setChildren] = useState<ChildItem[]>([])
|
||||
const [mergeMode, setMergeMode] = useState<'union' | 'intersect'>('union')
|
||||
const [minConfirm, setMinConfirm] = useState(0)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [search, setSearch] = useState('')
|
||||
// 记录鼠标按下时是否落在遮罩上, 避免拖选文本时鼠标移出面板导致误关。
|
||||
const mouseDownOnBackdrop = useRef(false)
|
||||
|
||||
// 拉取所有可用子策略(排除 composite 自身)
|
||||
const [available, setAvailable] = useState<ScreenerStrategy[]>([])
|
||||
const [loadingList, setLoadingList] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
// 重置表单
|
||||
setName('')
|
||||
setDescription('')
|
||||
// 创建模式自动生成 ID(composite_ + 时间戳), 编辑模式用现有 ID
|
||||
setStrategyId(isEdit ? (editStrategyId ?? '') : `composite_${Date.now().toString(36)}`)
|
||||
setChildren([])
|
||||
setMergeMode('union')
|
||||
setMinConfirm(0)
|
||||
setError('')
|
||||
setSearch('')
|
||||
setLoadingList(true)
|
||||
api.screenerStrategies()
|
||||
.then(data => {
|
||||
// 排除 composite 策略(不能嵌套)
|
||||
setAvailable((data.presets ?? []).filter(s => s.source !== 'composite'))
|
||||
})
|
||||
.catch(() => setAvailable([]))
|
||||
.finally(() => setLoadingList(false))
|
||||
// 编辑模式: 加载现有配置回显
|
||||
if (isEdit && editStrategyId) {
|
||||
api.strategyGet(editStrategyId)
|
||||
.then(detail => {
|
||||
setName(detail.name)
|
||||
setDescription(detail.description)
|
||||
setMergeMode((detail.params_defaults?.merge_mode as 'union' | 'intersect') ?? 'union')
|
||||
setMinConfirm(detail.params_defaults?.min_confirm ?? 0)
|
||||
if (detail.composite_children) {
|
||||
setChildren(detail.composite_children.map(c => ({ strategy_id: c.id, weight: c.weight })))
|
||||
}
|
||||
})
|
||||
.catch(() => {})
|
||||
}
|
||||
}, [open, isEdit, editStrategyId])
|
||||
|
||||
const filteredAvailable = useMemo(() => {
|
||||
const keyword = search.trim().toLowerCase()
|
||||
const selectedIds = new Set(children.map(c => c.strategy_id))
|
||||
return available.filter(s => {
|
||||
if (selectedIds.has(s.id)) return false
|
||||
if (!keyword) return true
|
||||
return s.name.toLowerCase().includes(keyword) || s.id.toLowerCase().includes(keyword)
|
||||
})
|
||||
}, [available, children, search])
|
||||
|
||||
const addChild = (s: ScreenerStrategy) => {
|
||||
setChildren(prev => [...prev, { strategy_id: s.id, weight: 1.0 }])
|
||||
}
|
||||
const removeChild = (id: string) => {
|
||||
setChildren(prev => prev.filter(c => c.strategy_id !== id))
|
||||
}
|
||||
const updateWeight = (id: string, weight: number) => {
|
||||
setChildren(prev => prev.map(c => c.strategy_id === id ? { ...c, weight } : c))
|
||||
}
|
||||
|
||||
const totalWeight = useMemo(
|
||||
() => children.reduce((sum, c) => sum + (c.weight || 0), 0),
|
||||
[children],
|
||||
)
|
||||
|
||||
const normalizeWeights = () => {
|
||||
if (totalWeight <= 0) return
|
||||
setChildren(prev => prev.map(c => ({ ...c, weight: Math.round((c.weight / totalWeight) * 1000) / 1000 })))
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
setError('')
|
||||
if (!name.trim()) {
|
||||
setError('请输入策略名称')
|
||||
return
|
||||
}
|
||||
if (children.length === 0) {
|
||||
setError('请至少选择一个子策略')
|
||||
return
|
||||
}
|
||||
setSaving(true)
|
||||
try {
|
||||
const result = await api.strategySaveComposite({
|
||||
strategy_id: isEdit ? (editStrategyId ?? '') : strategyId.trim(),
|
||||
name: name.trim(),
|
||||
description: description.trim(),
|
||||
children: children.map(c => ({ strategy_id: c.strategy_id, weight: c.weight })),
|
||||
merge_mode: mergeMode,
|
||||
min_confirm: minConfirm,
|
||||
mode: isEdit ? 'update' : 'create',
|
||||
})
|
||||
await onSavedId?.(result.strategy_id)
|
||||
setTimeout(() => onClose(), 800)
|
||||
} catch (e: any) {
|
||||
setError(String(e?.message ?? '保存失败'))
|
||||
}
|
||||
setSaving(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{open && (
|
||||
<motion.div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
onMouseDown={(e) => {
|
||||
mouseDownOnBackdrop.current = e.target === e.currentTarget
|
||||
}}
|
||||
onClick={(e) => {
|
||||
// 仅当按下和松开都在遮罩上才关闭, 避免面板内拖选文本误关。
|
||||
if (mouseDownOnBackdrop.current && e.target === e.currentTarget) onClose()
|
||||
}}
|
||||
>
|
||||
<motion.div
|
||||
className="relative flex max-h-[88vh] w-full max-w-3xl flex-col overflow-hidden rounded-panel border border-border bg-base shadow-2xl"
|
||||
initial={{ scale: 0.96, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
exit={{ scale: 0.96, opacity: 0 }}
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
{/* 头部 */}
|
||||
<div className="flex items-center gap-2 border-b border-border px-4 py-3">
|
||||
<Layers className="h-4 w-4 text-teal-400" />
|
||||
<span className="text-sm font-semibold text-foreground">
|
||||
{isEdit ? '编辑叠加策略' : '创建叠加策略'}
|
||||
</span>
|
||||
<span className="text-[10px] text-muted/60">
|
||||
引用多个子策略, 合并选股与回测信号
|
||||
</span>
|
||||
<button onClick={onClose} className="ml-auto text-muted hover:text-foreground">
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 主体 */}
|
||||
<div className="flex-1 overflow-y-auto p-4 space-y-4">
|
||||
{/* 基本信息 */}
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<div>
|
||||
<label className="mb-1 block text-xs text-muted">策略 ID(自动生成)</label>
|
||||
<input
|
||||
value={strategyId}
|
||||
readOnly
|
||||
disabled
|
||||
placeholder="composite_..."
|
||||
className="w-full rounded-btn border border-border bg-elevated px-2 py-1.5 font-mono text-[11px] text-muted cursor-not-allowed"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-xs text-muted">策略名称</label>
|
||||
<input
|
||||
value={name}
|
||||
onChange={e => setName(e.target.value)}
|
||||
placeholder="我的叠加策略"
|
||||
className="w-full rounded-btn border border-border bg-elevated px-2 py-1.5 text-xs text-foreground placeholder:text-muted/40"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-xs text-muted">描述(可选)</label>
|
||||
<input
|
||||
value={description}
|
||||
onChange={e => setDescription(e.target.value)}
|
||||
placeholder="说明这个叠加策略的意图"
|
||||
className="w-full rounded-btn border border-border bg-elevated px-2 py-1.5 text-xs text-foreground placeholder:text-muted/40"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 合并模式 */}
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<div>
|
||||
<label className="mb-1 block text-xs text-muted">合并模式</label>
|
||||
<select
|
||||
value={mergeMode}
|
||||
onChange={e => setMergeMode(e.target.value as 'union' | 'intersect')}
|
||||
className="w-full rounded-btn border border-border bg-elevated px-2 py-1.5 text-xs text-foreground"
|
||||
>
|
||||
<option value="union">并集(任一子策略命中即入选)</option>
|
||||
<option value="intersect">交集(至少 N 个子策略同时命中)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-xs text-muted">交集最少确认数(0=全部)</label>
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
value={minConfirm}
|
||||
onChange={e => setMinConfirm(Math.max(0, parseInt(e.target.value) || 0))}
|
||||
disabled={mergeMode !== 'intersect'}
|
||||
className="w-full rounded-btn border border-border bg-elevated px-2 py-1.5 text-xs text-foreground disabled:opacity-50"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 已选子策略 */}
|
||||
<div>
|
||||
<div className="mb-1.5 flex items-center justify-between">
|
||||
<label className="text-xs font-medium text-foreground">
|
||||
子策略({children.length})
|
||||
</label>
|
||||
<span className="text-[10px] text-muted flex items-center gap-1.5">
|
||||
权重总和: {totalWeight.toFixed(2)}
|
||||
{totalWeight > 0 && Math.abs(totalWeight - 1) > 0.001 && (
|
||||
<button onClick={normalizeWeights} className="text-teal-400 hover:text-teal-300 underline underline-offset-2">归一</button>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
{children.length === 0 && (
|
||||
<div className="rounded-btn border border-dashed border-border px-3 py-4 text-center text-xs text-muted">
|
||||
从下方列表选择子策略
|
||||
</div>
|
||||
)}
|
||||
{children.map(c => {
|
||||
const s = available.find(a => a.id === c.strategy_id)
|
||||
return (
|
||||
<div key={c.strategy_id} className="flex items-center gap-2 rounded-btn border border-border bg-elevated px-2 py-1.5">
|
||||
<span className="flex-1 truncate text-xs text-foreground">{s?.name ?? c.strategy_id}</span>
|
||||
{s?.source && (
|
||||
<span className={`rounded border px-1 text-[8px] ${BADGE_CLS[s.source] ?? ''}`}>
|
||||
{SRC_MAP[s.source] ?? s.source}
|
||||
</span>
|
||||
)}
|
||||
<input
|
||||
type="number"
|
||||
step={0.05}
|
||||
min={0}
|
||||
value={c.weight}
|
||||
onChange={e => updateWeight(c.strategy_id, parseFloat(e.target.value) || 0)}
|
||||
className="w-16 rounded border border-border bg-base px-1.5 py-0.5 text-[11px] text-foreground"
|
||||
/>
|
||||
<button onClick={() => removeChild(c.strategy_id)} className="text-danger/60 hover:text-danger">
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 可选子策略 */}
|
||||
<div>
|
||||
<label className="mb-1.5 block text-xs font-medium text-foreground">可选策略</label>
|
||||
<div className="relative mb-1.5">
|
||||
<Search className="absolute left-2 top-1/2 h-3 w-3 -translate-y-1/2 text-muted/50" />
|
||||
<input
|
||||
value={search}
|
||||
onChange={e => setSearch(e.target.value)}
|
||||
placeholder="搜索策略名称或 ID"
|
||||
className="w-full rounded-btn border border-border bg-elevated py-1.5 pl-7 pr-2 text-xs text-foreground placeholder:text-muted/40"
|
||||
/>
|
||||
</div>
|
||||
<div className="max-h-48 space-y-1 overflow-y-auto rounded-btn border border-border bg-elevated p-1.5">
|
||||
{loadingList && (
|
||||
<div className="flex items-center justify-center gap-1.5 py-3 text-xs text-muted">
|
||||
<Loader2 className="h-3 w-3 animate-spin" /> 加载中
|
||||
</div>
|
||||
)}
|
||||
{!loadingList && filteredAvailable.length === 0 && (
|
||||
<div className="py-3 text-center text-xs text-muted">无可用策略</div>
|
||||
)}
|
||||
{filteredAvailable.map(s => (
|
||||
<button
|
||||
key={s.id}
|
||||
onClick={() => addChild(s)}
|
||||
className="flex w-full items-center gap-1.5 rounded px-2 py-1 text-left text-xs text-foreground hover:bg-accent/10"
|
||||
>
|
||||
<Plus className="h-3 w-3 shrink-0 text-teal-400" />
|
||||
<span className="flex-1 truncate">{s.name}</span>
|
||||
{s.source && (
|
||||
<span className={`rounded border px-1 text-[8px] ${BADGE_CLS[s.source] ?? ''}`}>
|
||||
{SRC_MAP[s.source] ?? s.source}
|
||||
</span>
|
||||
)}
|
||||
<span className="font-mono text-[9px] text-muted/50">{s.id}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="rounded-btn border border-danger/30 bg-danger/10 px-3 py-2 text-xs text-danger">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 底部 */}
|
||||
<div className="flex items-center justify-end gap-2 border-t border-border px-4 py-3">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="rounded-btn border border-border px-3 py-1.5 text-xs text-muted hover:text-foreground"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={saving}
|
||||
className="inline-flex items-center gap-1.5 rounded-btn bg-teal-500/15 px-3 py-1.5 text-xs font-medium text-teal-400 border border-teal-500/30 hover:bg-teal-500/25 disabled:opacity-50"
|
||||
>
|
||||
{saving && <Loader2 className="h-3 w-3 animate-spin" />}
|
||||
{isEdit ? '保存修改' : '创建'}
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
)
|
||||
}
|
||||
@@ -63,11 +63,12 @@ export function cardWrapCls(size: CardSize): string {
|
||||
|
||||
// ===== 来源标签 =====
|
||||
|
||||
const SRC_MAP: Record<string, string> = { builtin: '内置', custom: '自定义', ai: 'AI' }
|
||||
const SRC_MAP: Record<string, string> = { builtin: '内置', custom: '自定义', ai: 'AI', composite: '叠加' }
|
||||
const BADGE_CLS_MAP: Record<string, string> = {
|
||||
builtin: 'bg-secondary/10 text-muted border-border',
|
||||
ai: 'bg-purple-500/10 text-purple-400 border-purple-500/30',
|
||||
custom: 'bg-amber-400/10 text-amber-400 border-amber-400/30',
|
||||
composite: 'bg-teal-500/10 text-teal-400 border-teal-500/30',
|
||||
}
|
||||
|
||||
// ===== 策略卡片 =====
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useState, useMemo, useEffect, useCallback, useRef } from 'react'
|
||||
import { motion, AnimatePresence, Reorder } from 'framer-motion'
|
||||
import { X, Plus, GripVertical, Upload, Loader2 } from 'lucide-react'
|
||||
import { api, type StrategyDetail } from '@/lib/api'
|
||||
import { useDialogBackdrop } from '@/lib/useDialogBackdrop'
|
||||
|
||||
interface Props {
|
||||
pool: string[]
|
||||
@@ -42,6 +43,7 @@ function fileStem(name: string): string {
|
||||
}
|
||||
|
||||
export function StrategyPoolDialog({ pool, onConfirm, onClose }: Props) {
|
||||
const backdrop = useDialogBackdrop(onClose)
|
||||
// 草稿状态: 打开时从 pool 复制, 操作只改草稿, 点确定才提交
|
||||
const [draftPool, setDraftPool] = useState<string[]>(() => [...pool])
|
||||
const [allStrategies, setAllStrategies] = useState<StrategyDetail[]>([])
|
||||
@@ -121,7 +123,7 @@ export function StrategyPoolDialog({ pool, onConfirm, onClose }: Props) {
|
||||
mode: 'create',
|
||||
})
|
||||
await loadStrategies()
|
||||
setActiveTab(result.source)
|
||||
setActiveTab(result.source === 'ai' ? 'ai' : 'custom')
|
||||
setImportMsg(`已导入到${result.source === 'ai' ? 'AI' : '自定义'}策略: ${result.strategy_id}`)
|
||||
} catch (e: any) {
|
||||
setImportError(String(e?.message ?? '导入失败'))
|
||||
@@ -138,7 +140,7 @@ export function StrategyPoolDialog({ pool, onConfirm, onClose }: Props) {
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50"
|
||||
onClick={e => { if (e.target === e.currentTarget) onClose() }}
|
||||
{...backdrop}
|
||||
>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.95, y: 10 }}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { motion, AnimatePresence } from 'framer-motion'
|
||||
import { X, Settings2, RotateCcw, Save, ChevronDown, Filter, Star, TrendingUp, Sparkles, Download } from 'lucide-react'
|
||||
import { api, type StrategyDetail, type StrategyParamDef } from '@/lib/api'
|
||||
import { X, Settings2, RotateCcw, Save, ChevronDown, Filter, Star, TrendingUp, Sparkles, Download, Layers, Plus, Trash2 } from 'lucide-react'
|
||||
import { api, type StrategyDetail, type StrategyParamDef, type CompositeChildInfo } from '@/lib/api'
|
||||
import { BUILTIN_COLUMNS } from '@/lib/watchlist-columns'
|
||||
import { color } from '@/lib/colors'
|
||||
import { SignalPicker } from './SignalPicker'
|
||||
@@ -216,6 +216,11 @@ export function StrategySettingsDialog({ strategyId, onClose, onSaved, onAiModif
|
||||
const [exitSignals, setExitSignals] = useState<string[]>([])
|
||||
const [displayLimit, setDisplayLimit] = useState<number | null>(null)
|
||||
const [basicFilterEnabled, setBasicFilterEnabled] = useState(true)
|
||||
// 叠加策略: 子策略列表与权重(composite 专属, 编辑权重后随 override 保存)
|
||||
const [compositeChildren, setCompositeChildren] = useState<CompositeChildInfo[]>([])
|
||||
// 可选子策略列表 + 添加面板开关(composite 设置用)
|
||||
const [allStrategies, setAllStrategies] = useState<{ id: string; name: string; source?: string }[]>([])
|
||||
const [showAddChild, setShowAddChild] = useState(false)
|
||||
const [editingScoring, setEditingScoring] = useState(false)
|
||||
const [deleting, setDeleting] = useState(false)
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
|
||||
@@ -247,11 +252,32 @@ export function StrategySettingsDialog({ strategyId, onClose, onSaved, onAiModif
|
||||
setExitSignals(d.exit_signals ?? [])
|
||||
setDisplayLimit(d.display_limit ?? null)
|
||||
setBasicFilterEnabled(d.basic_filter?.enabled !== false)
|
||||
setCompositeChildren(d.composite_children ?? [])
|
||||
// composite 策略: 加载全部可选子策略(排除自身和其他 composite)供添加
|
||||
if (d.source === 'composite') {
|
||||
api.screenerStrategies().then(data => {
|
||||
setAllStrategies((data.presets ?? []).filter(s => s.id !== strategyId && s.source !== 'composite'))
|
||||
}).catch(() => setAllStrategies([]))
|
||||
}
|
||||
})
|
||||
.catch(() => setDetail(null))
|
||||
.finally(() => setLoading(false))
|
||||
}, [strategyId])
|
||||
|
||||
// 叠加策略: 权重归一(总和→1.0)
|
||||
const compositeTotal = compositeChildren.reduce((s, c) => s + (c.weight || 0), 0)
|
||||
const normalizeCompositeWeights = () => {
|
||||
if (compositeTotal <= 0) return
|
||||
setCompositeChildren(prev => prev.map(c => ({ ...c, weight: Math.round((c.weight / compositeTotal) * 1000) / 1000 })))
|
||||
}
|
||||
const removeCompositeChild = (id: string) => {
|
||||
setCompositeChildren(prev => prev.filter(c => c.id !== id))
|
||||
}
|
||||
const addCompositeChild = (s: { id: string; name: string; source?: string }) => {
|
||||
setCompositeChildren(prev => [...prev, { id: s.id, name: s.name, source: s.source ?? '', weight: 1.0 }])
|
||||
setShowAddChild(false)
|
||||
}
|
||||
|
||||
// 保存
|
||||
const handleSave = async () => {
|
||||
if (!strategyId) return
|
||||
@@ -268,6 +294,10 @@ export function StrategySettingsDialog({ strategyId, onClose, onSaved, onAiModif
|
||||
entry_signals: entrySignals,
|
||||
exit_signals: exitSignals,
|
||||
display_limit: displayLimit,
|
||||
// 叠加策略: 子策略权重(composite 专属, 走 override.children 持久化)
|
||||
...(detail?.source === 'composite'
|
||||
? { children: compositeChildren.map(c => ({ strategy_id: c.id, weight: c.weight })) }
|
||||
: {}),
|
||||
})
|
||||
onSaved?.(displayLimit)
|
||||
onClose()
|
||||
@@ -298,6 +328,7 @@ export function StrategySettingsDialog({ strategyId, onClose, onSaved, onAiModif
|
||||
setExitSignals(d.exit_signals ?? [])
|
||||
setDisplayLimit(d.display_limit ?? null)
|
||||
setBasicFilterEnabled(d.basic_filter?.enabled !== false)
|
||||
setCompositeChildren(d.composite_children ?? [])
|
||||
} finally {
|
||||
setResetting(false)
|
||||
}
|
||||
@@ -347,7 +378,7 @@ export function StrategySettingsDialog({ strategyId, onClose, onSaved, onAiModif
|
||||
<div className="flex items-center gap-2.5">
|
||||
<Settings2 className="h-4 w-4 text-accent" />
|
||||
<span id="strategy-settings-title" className="text-sm font-semibold text-foreground">{detail?.name ?? strategyId}</span>
|
||||
{detail && <span className="text-[10px] px-1.5 py-0.5 rounded bg-elevated text-muted">{{ builtin: '内置', custom: '自定义', ai: 'AI' }[detail.source] ?? detail.source}</span>}
|
||||
{detail && <span className="text-[10px] px-1.5 py-0.5 rounded bg-elevated text-muted">{{ builtin: '内置', custom: '自定义', ai: 'AI', composite: '叠加' }[detail.source] ?? detail.source}</span>}
|
||||
<span className="text-[10px] text-muted/40 font-mono">{strategyId}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -394,7 +425,87 @@ export function StrategySettingsDialog({ strategyId, onClose, onSaved, onAiModif
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 三列 */}
|
||||
{/* 叠加策略: 子策略列表 + 权重(替换三列参数, composite 专属) */}
|
||||
{detail.source === 'composite' ? (() => {
|
||||
const SRC_LABEL: Record<string, string> = { builtin: '内置', custom: '自定义', ai: 'AI' }
|
||||
const SRC_CLS: Record<string, string> = {
|
||||
builtin: 'border-accent/25 bg-accent/10 text-accent',
|
||||
custom: 'border-amber-400/25 bg-amber-400/10 text-amber-400',
|
||||
ai: 'border-purple-500/25 bg-purple-500/10 text-purple-400',
|
||||
}
|
||||
const selectedIds = new Set(compositeChildren.map(c => c.id))
|
||||
const candidates = allStrategies.filter(s => !selectedIds.has(s.id))
|
||||
return (
|
||||
<div className="rounded-xl border border-teal-500/20 bg-teal-500/5 p-4 space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Layers className="h-4 w-4 text-teal-400" />
|
||||
<span className="text-sm font-medium text-foreground">子策略与权重</span>
|
||||
<span className="text-[10px] text-muted flex items-center gap-1.5">
|
||||
共 {compositeChildren.length} 个 · 权重总和 {compositeTotal.toFixed(2)}
|
||||
{compositeTotal > 0 && Math.abs(compositeTotal - 1) > 0.001 && (
|
||||
<button onClick={normalizeCompositeWeights} className="text-teal-400 hover:text-teal-300 underline underline-offset-2">归一</button>
|
||||
)}
|
||||
</span>
|
||||
<button onClick={() => setShowAddChild(v => !v)} className="ml-auto inline-flex items-center gap-1 h-6 px-2 rounded-lg border border-teal-500/30 bg-teal-500/10 text-[11px] text-teal-400 hover:bg-teal-500/20">
|
||||
<Plus className="h-3 w-3" />添加
|
||||
</button>
|
||||
</div>
|
||||
{/* 添加子策略面板 */}
|
||||
{showAddChild && (
|
||||
<div className="rounded-lg border border-border bg-base/60 p-2 space-y-1 max-h-48 overflow-y-auto">
|
||||
{candidates.length === 0 ? (
|
||||
<div className="text-[11px] text-muted py-2 text-center">无可添加的策略</div>
|
||||
) : candidates.map(s => (
|
||||
<button key={s.id} onClick={() => addCompositeChild(s)} className="flex w-full items-center gap-1.5 rounded px-2 py-1 text-left hover:bg-teal-500/10">
|
||||
<Plus className="h-3 w-3 shrink-0 text-teal-400" />
|
||||
<span className="flex-1 truncate text-xs text-foreground">{s.name}</span>
|
||||
{s.source && (
|
||||
<span className={`rounded border px-1 text-[8px] ${SRC_CLS[s.source] ?? ''}`}>{SRC_LABEL[s.source] ?? s.source}</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{compositeChildren.length === 0 ? (
|
||||
<div className="text-xs text-muted py-4 text-center">暂无子策略, 点击"添加"选择</div>
|
||||
) : (
|
||||
<div className="space-y-1.5">
|
||||
{compositeChildren.map((c, i) => (
|
||||
<div key={c.id} className="flex items-center gap-2 rounded-lg bg-base/60 px-3 py-2">
|
||||
<span className="text-[10px] text-muted/50 font-mono w-5">{i + 1}</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-xs font-medium text-foreground truncate">{c.name || c.id}</span>
|
||||
{c.source && (
|
||||
<span className={`rounded border px-1 text-[8px] shrink-0 ${SRC_CLS[c.source] ?? ''}`}>{SRC_LABEL[c.source] ?? c.source}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-[10px] text-muted/50 font-mono">{c.id}</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
<input
|
||||
type="number"
|
||||
step={0.05}
|
||||
min={0}
|
||||
value={c.weight}
|
||||
onChange={e => setCompositeChildren(prev => prev.map((p, j) => j === i ? { ...p, weight: parseFloat(e.target.value) || 0 } : p))}
|
||||
className="w-16 h-7 px-1.5 rounded-lg bg-base border border-border/40 text-xs font-mono text-foreground text-center focus:outline-none focus:border-accent/50"
|
||||
/>
|
||||
<button onClick={() => removeCompositeChild(c.id)} className="text-danger/50 hover:text-danger p-1">
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="text-[10px] text-muted/60 pt-1 border-t border-border/30">
|
||||
提示: 权重建议归一为 1.0; 修改后点底部"保存设置"生效。
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})()
|
||||
: (
|
||||
<div className="grid grid-cols-3 gap-5 items-start">
|
||||
{/* 列1:选股条件 */}
|
||||
<Section icon={Filter} title="基础参数" accent="text-sky-400">
|
||||
@@ -557,6 +668,7 @@ export function StrategySettingsDialog({ strategyId, onClose, onSaved, onAiModif
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div className="flex items-center justify-center py-16 text-sm text-muted">加载失败</div>
|
||||
@@ -570,7 +682,7 @@ export function StrategySettingsDialog({ strategyId, onClose, onSaved, onAiModif
|
||||
className="inline-flex items-center gap-1.5 h-8 px-3 rounded-lg border border-border bg-surface text-xs text-secondary hover:text-danger hover:border-danger/30 transition-colors cursor-pointer disabled:opacity-50">
|
||||
<RotateCcw className="h-3.5 w-3.5" />{resetting ? '重置中…' : '重置默认'}
|
||||
</button>
|
||||
{(detail?.source === 'ai' || detail?.source === 'custom') && (
|
||||
{(detail?.source === 'ai' || detail?.source === 'custom' || detail?.source === 'composite') && (
|
||||
<button onClick={() => { setDeleteError(''); setShowDeleteConfirm(true) }}
|
||||
className="text-[10px] text-danger hover:text-danger/80 transition-colors">删除策略</button>
|
||||
)}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { motion, AnimatePresence } from 'framer-motion'
|
||||
import { X, Store, Hammer, Download, Zap } from 'lucide-react'
|
||||
import { useDialogBackdrop } from '@/lib/useDialogBackdrop'
|
||||
|
||||
interface Props {
|
||||
open: boolean
|
||||
@@ -11,6 +12,7 @@ interface Props {
|
||||
* 目标:不定时更新更多策略。功能正在建设中,当前仅占位。
|
||||
*/
|
||||
export function StrategyStoreDialog({ open, onClose }: Props) {
|
||||
const backdrop = useDialogBackdrop(onClose)
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{open && (
|
||||
@@ -19,7 +21,7 @@ export function StrategyStoreDialog({ open, onClose }: Props) {
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50"
|
||||
onClick={e => { if (e.target === e.currentTarget) onClose() }}
|
||||
{...backdrop}
|
||||
>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.95, y: 10 }}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { ArrowRight, Plus, Save, Search, X } from 'lucide-react'
|
||||
import { api, type CustomSignal, type CustomSignalCondition, type CustomSignalFieldGroup } from '@/lib/api'
|
||||
import { QK } from '@/lib/queryKeys'
|
||||
import { useDialogBackdrop } from '@/lib/useDialogBackdrop'
|
||||
|
||||
interface Props {
|
||||
open: boolean
|
||||
@@ -21,6 +22,7 @@ const emptySignal = (kind: CustomSignal['kind'] = 'exit'): CustomSignal => ({
|
||||
|
||||
export function CustomSignalDialog({ open, signal, defaultKind = 'exit', onClose, onSaved }: Props) {
|
||||
const qc = useQueryClient()
|
||||
const backdrop = useDialogBackdrop(onClose)
|
||||
const options = useQuery({ queryKey: QK.customSignalsOptions, queryFn: api.customSignalsOptions, enabled: open })
|
||||
|
||||
const [draft, setDraft] = useState<CustomSignal>(() => emptySignal(defaultKind))
|
||||
@@ -71,7 +73,7 @@ export function CustomSignalDialog({ open, signal, defaultKind = 'exit', onClose
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="fixed inset-0 z-[70] flex items-center justify-center bg-black/40 backdrop-blur-sm p-4"
|
||||
onClick={onClose}
|
||||
{...backdrop}
|
||||
>
|
||||
<motion.div
|
||||
role="dialog"
|
||||
@@ -184,6 +186,7 @@ function FieldPicker({ value, fields, groups, onChange }: {
|
||||
}) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [query, setQuery] = useState('')
|
||||
const backdrop = useDialogBackdrop(() => setOpen(false))
|
||||
const selectedLabel = fields.find(f => f.key === value)?.label ?? value
|
||||
|
||||
const filteredGroups = useMemo(() => {
|
||||
@@ -217,7 +220,7 @@ function FieldPicker({ value, fields, groups, onChange }: {
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="fixed inset-0 z-[9999] flex items-center justify-center bg-black/50 backdrop-blur-sm p-4"
|
||||
onClick={() => setOpen(false)}
|
||||
{...backdrop}
|
||||
>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.95, y: 10 }}
|
||||
|
||||
@@ -7,6 +7,7 @@ import { LEVEL_GROUPS } from './AnalysisKChart'
|
||||
import { api, genRuleId, type MonitorRule, type PriceLevel } from '@/lib/api'
|
||||
import { QK } from '@/lib/queryKeys'
|
||||
import { usePreferences } from '@/lib/useSharedQueries'
|
||||
import { useDialogBackdrop } from '@/lib/useDialogBackdrop'
|
||||
|
||||
interface Props {
|
||||
symbol: string
|
||||
@@ -37,6 +38,7 @@ function levelGroupLabel(level: PriceLevel) {
|
||||
|
||||
export function PriceAlertDialog({ symbol, name, onClose }: Props) {
|
||||
const qc = useQueryClient()
|
||||
const backdrop = useDialogBackdrop(onClose)
|
||||
const { data: prefs } = usePreferences()
|
||||
const levelsQuery = useQuery({
|
||||
queryKey: QK.stockLevels(symbol),
|
||||
@@ -189,7 +191,7 @@ export function PriceAlertDialog({ symbol, name, onClose }: Props) {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/55 p-3 backdrop-blur-sm sm:p-4" onClick={onClose}>
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/55 p-3 backdrop-blur-sm sm:p-4" {...backdrop}>
|
||||
<div role="dialog" aria-modal="true" aria-labelledby="price-alert-title" className="flex max-h-[88vh] w-full max-w-2xl flex-col overflow-hidden rounded-lg border border-border bg-surface shadow-2xl" onClick={event => event.stopPropagation()}>
|
||||
<header className="flex items-center gap-3 border-b border-border/60 px-5 py-3.5">
|
||||
<span className="grid h-8 w-8 shrink-0 place-items-center rounded-md border border-sky-400/25 bg-sky-400/10 text-sky-400">
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
type ActiveTask, type HistoryReport,
|
||||
minimizeDialog, closeDialog, startAnalysis,
|
||||
} from '@/lib/stockAnalysisStore'
|
||||
import { useDialogBackdrop } from '@/lib/useDialogBackdrop'
|
||||
|
||||
/**
|
||||
* AI 个股分析对话框 —— 蓝色主题,与财务分析对话框区分。
|
||||
@@ -80,6 +81,8 @@ export function StockAnalysisDialog({ task, mode, minimized }: Props) {
|
||||
setTimeout(() => setCopied(false), 2000)
|
||||
}
|
||||
|
||||
const backdrop = useDialogBackdrop(closeDialog, () => !isWorking)
|
||||
|
||||
if (!open) return null
|
||||
|
||||
const error = task && 'error' in task ? task.error : ''
|
||||
@@ -89,7 +92,7 @@ export function StockAnalysisDialog({ task, mode, minimized }: Props) {
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }}
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm p-4"
|
||||
onClick={e => { if (e.target === e.currentTarget && !isWorking) closeDialog() }}
|
||||
{...backdrop}
|
||||
>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.96, y: 12 }} animate={{ opacity: 1, scale: 1, y: 0 }} exit={{ opacity: 0, scale: 0.96, y: 12 }}
|
||||
|
||||
+30
-3
@@ -426,13 +426,20 @@ export interface StrategyParamDef {
|
||||
options?: string[]
|
||||
}
|
||||
|
||||
export interface CompositeChildInfo {
|
||||
id: string
|
||||
name: string
|
||||
source: string
|
||||
weight: number
|
||||
}
|
||||
|
||||
export interface StrategyDetail {
|
||||
id: string
|
||||
name: string
|
||||
description: string
|
||||
tags: string[]
|
||||
source: 'builtin' | 'custom' | 'ai'
|
||||
execution_backend: 'polars_expr' | 'matrix_native' | 'python_history_legacy'
|
||||
source: 'builtin' | 'custom' | 'ai' | 'composite'
|
||||
execution_backend: 'polars_expr' | 'matrix_native' | 'python_history_legacy' | 'composite'
|
||||
asset_types: string[]
|
||||
timeframes: string[]
|
||||
version: string
|
||||
@@ -454,6 +461,8 @@ export interface StrategyDetail {
|
||||
order_by: string
|
||||
descending: boolean
|
||||
limit: number
|
||||
// 叠加策略(composite)专属: 子策略列表与合并模式。非 composite 时为 null。
|
||||
composite_children?: CompositeChildInfo[] | null
|
||||
}
|
||||
|
||||
export interface StrategyBuildResult {
|
||||
@@ -472,7 +481,7 @@ export type StrategyBuildStreamEvent =
|
||||
export interface StrategyCodeSaveResult {
|
||||
ok: boolean
|
||||
strategy_id: string
|
||||
source: 'ai' | 'custom'
|
||||
source: 'ai' | 'custom' | 'composite'
|
||||
path: string
|
||||
meta: Record<string, any>
|
||||
}
|
||||
@@ -733,6 +742,9 @@ export interface StrategyBacktestResult {
|
||||
score_max: number | null
|
||||
max_hold_days: number | null
|
||||
source: string
|
||||
execution_backend?: string
|
||||
// 叠加策略回测: 子策略构成与权重归因
|
||||
composite_children?: { id: string; weight: number }[]
|
||||
}
|
||||
elapsed_ms: number
|
||||
error: string | null
|
||||
@@ -2186,6 +2198,21 @@ export const api = {
|
||||
body: JSON.stringify(payload),
|
||||
}),
|
||||
|
||||
/** 创建/更新叠加策略(composite): 声明式引用多个子策略 */
|
||||
strategySaveComposite: (payload: {
|
||||
strategy_id: string
|
||||
name: string
|
||||
description?: string
|
||||
children: { strategy_id: string; weight: number }[]
|
||||
merge_mode: 'union' | 'intersect'
|
||||
min_confirm?: number
|
||||
mode: 'create' | 'update'
|
||||
}) =>
|
||||
request<StrategyCodeSaveResult>('/api/strategies/composite/save', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
}),
|
||||
|
||||
/** 保存 AI 生成的策略文件 */
|
||||
strategySaveCode: (strategyId: string, code: string, meta?: { name?: string; description?: string }) =>
|
||||
request<{ ok: boolean; path: string }>('/api/strategies/ai/save', {
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { useRef, useCallback } from 'react'
|
||||
|
||||
/**
|
||||
* 对话框遮罩"点击外部关闭"的共享逻辑, 防止拖拽穿透。
|
||||
|
||||
* 问题: 用户在对话框内容内按下鼠标拖选文本, 鼠标移到遮罩上松开时,
|
||||
* 浏览器仍会触发遮罩的 click 事件 (click 派发给 mousedown/mouseup 的共同祖先),
|
||||
* 导致对话框意外关闭。
|
||||
|
||||
* 解法: 记录 mousedown 时是否落在遮罩本身上; 仅当 mousedown 和 mouseup(click)
|
||||
* 都发生在遮罩上时才触发关闭。
|
||||
|
||||
* 用法:
|
||||
* const backdrop = useDialogBackdrop(onClose)
|
||||
* <div className="fixed inset-0 ..." {...backdrop}>
|
||||
* <div onClick={e => e.stopPropagation()}>内容</div>
|
||||
* </div>
|
||||
*
|
||||
* 对于有额外条件(如 isWorking 时禁止关闭)的场景, 传 enabled 回调:
|
||||
* const backdrop = useDialogBackdrop(onClose, () => !isWorking)
|
||||
*/
|
||||
export function useDialogBackdrop(
|
||||
onClose: () => void,
|
||||
enabled?: () => boolean,
|
||||
) {
|
||||
const mouseDownOnBackdrop = useRef(false)
|
||||
|
||||
const onMouseDown = useCallback((e: React.MouseEvent) => {
|
||||
mouseDownOnBackdrop.current = e.target === e.currentTarget
|
||||
}, [])
|
||||
|
||||
const onClick = useCallback((e: React.MouseEvent) => {
|
||||
if (enabled && !enabled()) return
|
||||
if (mouseDownOnBackdrop.current && e.target === e.currentTarget) {
|
||||
onClose()
|
||||
}
|
||||
}, [onClose, enabled])
|
||||
|
||||
return { onMouseDown, onClick }
|
||||
}
|
||||
@@ -14,6 +14,7 @@ import { EmptyState } from '@/components/EmptyState'
|
||||
import { useTheme } from '@/lib/theme'
|
||||
import { useCapabilities, usePreferences } from '@/lib/useSharedQueries'
|
||||
import { SealedBadge } from '@/components/SealedBadge'
|
||||
import { useDialogBackdrop } from '@/lib/useDialogBackdrop'
|
||||
import type { ExtColumnDisplayConfig } from '@/lib/watchlist-columns'
|
||||
|
||||
// ===== Ext 字段配置 =====
|
||||
@@ -407,6 +408,7 @@ function MonitorMenu({ stock, direction, sealMode, monitorRule, anchorRect, hasD
|
||||
// 推送渠道默认值: 取偏好设置中的全局默认 (已有规则沿用其值)
|
||||
const { data: prefs } = usePreferences()
|
||||
const webhookDefaultChannels = prefs?.webhook_default_channels ?? []
|
||||
const backdrop = useDialogBackdrop(onClose)
|
||||
|
||||
// 单位倍率: 输入值 × 倍率 = 原始单位 (量=手, 额=元)
|
||||
const VOL_UNITS = [
|
||||
@@ -507,7 +509,7 @@ function MonitorMenu({ stock, direction, sealMode, monitorRule, anchorRect, hasD
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="fixed inset-0 z-40" onClick={onClose} />
|
||||
<div className="fixed inset-0 z-40" {...backdrop} />
|
||||
<div
|
||||
className="fixed z-50 w-60 rounded-lg bg-surface border border-border shadow-xl text-xs overflow-hidden"
|
||||
style={{ left, top }}
|
||||
@@ -1357,6 +1359,7 @@ function ExtConfigDialog({ fields, onSave, onClose }: {
|
||||
onClose: () => void
|
||||
}) {
|
||||
const [draft, setDraft] = useState(fields)
|
||||
const backdrop = useDialogBackdrop(onClose)
|
||||
const { data: schemaData } = useQuery({
|
||||
queryKey: QK.extDataSchemaAll,
|
||||
queryFn: api.extDataSchemaAll,
|
||||
@@ -1377,7 +1380,7 @@ function ExtConfigDialog({ fields, onSave, onClose }: {
|
||||
}, [schemaData])
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50" onClick={onClose}>
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50" {...backdrop}>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.95 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
|
||||
@@ -8,6 +8,7 @@ import { Skeleton } from '@/components/data/Skeleton'
|
||||
import { api, type MonitorRule, type AlertEvent, type MonitorCondition, type MonitorExtFieldItem } from '@/lib/api'
|
||||
import { QK } from '@/lib/queryKeys'
|
||||
import { fmtPrice, fmtPct } from '@/lib/format'
|
||||
import { useDialogBackdrop } from '@/lib/useDialogBackdrop'
|
||||
import { cn } from '@/lib/cn'
|
||||
import { cnSignal } from '@/lib/signals'
|
||||
import { LEGACY_STRATEGY_NOTIFY_EVENTS, STRATEGY_NOTIFY_EVENT_OPTIONS, strategyEventMeta, strategyName } from '@/lib/strategyMonitorEvents'
|
||||
@@ -782,6 +783,7 @@ function RulesList({ rulesQuery, onEdit }: {
|
||||
|
||||
// ── 规则编辑对话框 ────────────────────────────────────
|
||||
function RuleEditorDialog({ open, rule, onClose }: { open: boolean; rule: MonitorRule | null; onClose: () => void }) {
|
||||
const backdrop = useDialogBackdrop(onClose)
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{open && (
|
||||
@@ -790,7 +792,7 @@ function RuleEditorDialog({ open, rule, onClose }: { open: boolean; rule: Monito
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="fixed inset-0 z-50 flex items-start justify-center overflow-auto bg-black/40 backdrop-blur-sm p-4"
|
||||
onClick={onClose}
|
||||
{...backdrop}
|
||||
>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.96, y: 8 }}
|
||||
|
||||
@@ -22,6 +22,7 @@ import { StrategySettingsDialog } from '@/components/screener/StrategySettingsDi
|
||||
import { StrategyPoolDialog } from '@/components/screener/StrategyPoolDialog'
|
||||
import { StrategyBuilderDialog } from '@/components/screener/StrategyBuilderDialog'
|
||||
import { StrategyStoreDialog } from '@/components/screener/StrategyStoreDialog'
|
||||
import { CompositeStrategyDialog } from '@/components/screener/CompositeStrategyDialog'
|
||||
import { ListColumnCustomizer } from '@/components/ListColumnCustomizer'
|
||||
import { useTableSort } from '@/components/stock-table/useTableSort'
|
||||
import { resolveCandleConfig } from '@/lib/list-columns'
|
||||
@@ -48,6 +49,7 @@ export function Screener() {
|
||||
const [showBuilder, setShowBuilder] = useState(false)
|
||||
const [builderMode, setBuilderMode] = useState<'create' | 'modify'>('create')
|
||||
const [showStore, setShowStore] = useState(false)
|
||||
const [showComposite, setShowComposite] = useState(false)
|
||||
const { pool, addToPool, removeFromPool, reorderPool, prune } = useStrategyPool()
|
||||
const [cardSize, setCardSize] = useState<CardSize>(loadCardSize)
|
||||
// 日k蜡烛图显示开关(仅当 candle 列可见时才有意义;持久化)
|
||||
@@ -666,6 +668,16 @@ export function Screener() {
|
||||
{visiblePool.length}/{strategyPresets.length}
|
||||
</span>
|
||||
</button>
|
||||
{/* 创建叠加策略 */}
|
||||
<button
|
||||
onClick={() => setShowComposite(true)}
|
||||
className="inline-flex items-center gap-1.5 h-7 px-3 rounded-btn
|
||||
text-xs font-medium text-teal-400 border border-teal-500/20 bg-teal-500/5
|
||||
hover:bg-teal-500/15 transition-colors cursor-pointer"
|
||||
>
|
||||
<Layers className="h-3.5 w-3.5" />
|
||||
叠加策略
|
||||
</button>
|
||||
{/* 创建策略 */}
|
||||
<button
|
||||
onClick={() => { setBuilderMode('create'); setShowBuilder(true) }}
|
||||
@@ -995,6 +1007,15 @@ export function Screener() {
|
||||
}}
|
||||
/>
|
||||
|
||||
<CompositeStrategyDialog
|
||||
open={showComposite}
|
||||
onClose={() => setShowComposite(false)}
|
||||
onSavedId={async id => {
|
||||
await qc.fetchQuery({ queryKey: QK.screenerStrategies('all'), queryFn: () => api.screenerStrategies(), staleTime: 0 })
|
||||
addToPool(id)
|
||||
}}
|
||||
/>
|
||||
|
||||
<StrategyStoreDialog
|
||||
open={showStore}
|
||||
onClose={() => setShowStore(false)}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState, useMemo, useEffect, useRef, type ReactNode } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { motion, AnimatePresence } from 'framer-motion'
|
||||
import { Play, FlaskConical, Clock, Loader2, Square, Search, Plus, X, SlidersHorizontal, BarChart3, Gauge, Zap, ListPlus, HelpCircle, ChevronRight, AlertTriangle } from 'lucide-react'
|
||||
import { Play, FlaskConical, Clock, Loader2, Square, Search, Plus, X, SlidersHorizontal, BarChart3, Gauge, Zap, ListPlus, HelpCircle, ChevronRight, AlertTriangle, Layers } from 'lucide-react'
|
||||
import {
|
||||
api,
|
||||
type StrategyBacktestResult,
|
||||
@@ -149,12 +149,13 @@ function FillRuleHint() {
|
||||
)
|
||||
}
|
||||
|
||||
const SRC_MAP: Record<string, string> = { builtin: '内置', custom: '自定义', ai: 'AI' }
|
||||
const SRC_MAP: Record<string, string> = { builtin: '内置', custom: '自定义', ai: 'AI', composite: '叠加' }
|
||||
const TRADE_PAGE_SIZE_OPTIONS = [10, 20, 30, 50, 100]
|
||||
const BADGE_CLS_MAP: Record<string, string> = {
|
||||
builtin: 'bg-secondary/10 text-muted border-border',
|
||||
ai: 'bg-purple-500/10 text-purple-400 border-purple-500/30',
|
||||
custom: 'bg-amber-400/10 text-amber-400 border-amber-400/30',
|
||||
composite: 'bg-teal-500/10 text-teal-400 border-teal-500/30',
|
||||
}
|
||||
const FIELD_LABEL: Record<string, string> = {}
|
||||
for (const c of BUILTIN_COLUMNS) {
|
||||
@@ -179,11 +180,12 @@ const BASIC_FILTER_FIELDS = [
|
||||
{ key: 'turnover_max', label: '最高换手率', unit: '%' },
|
||||
]
|
||||
type AdvancedSettingsTab = 'params' | 'filter' | 'entry' | 'exit' | 'scoring' | 'risk' | 'range'
|
||||
type StrategyGroup = 'all' | 'custom' | 'ai' | 'builtin'
|
||||
type StrategyGroup = 'all' | 'custom' | 'ai' | 'builtin' | 'composite'
|
||||
const STRATEGY_GROUPS: { id: StrategyGroup; label: string }[] = [
|
||||
{ id: 'all', label: '全部' },
|
||||
{ id: 'custom', label: '自定义' },
|
||||
{ id: 'ai', label: 'AI' },
|
||||
{ id: 'composite', label: '叠加' },
|
||||
{ id: 'builtin', label: '内置' },
|
||||
]
|
||||
const ADVANCED_TABS: { id: AdvancedSettingsTab; label: string }[] = [
|
||||
@@ -1189,11 +1191,15 @@ export function StrategyBacktest() {
|
||||
|
||||
const detail = strategyDetail.data
|
||||
const matrixStrategy = detail?.execution_backend === 'matrix_native'
|
||||
const compositeStrategy = detail?.source === 'composite'
|
||||
const visibleAdvancedTabs = useMemo(
|
||||
() => matrixStrategy
|
||||
? ADVANCED_TABS.filter(tab => tab.id !== 'entry' && tab.id !== 'exit')
|
||||
: ADVANCED_TABS,
|
||||
[matrixStrategy],
|
||||
: compositeStrategy
|
||||
// composite 的 entry/exit/scoring 由子策略决定, composite 层只调合并参数(params Tab)
|
||||
? ADVANCED_TABS.filter(tab => tab.id !== 'entry' && tab.id !== 'exit' && tab.id !== 'scoring')
|
||||
: ADVANCED_TABS,
|
||||
[matrixStrategy, compositeStrategy],
|
||||
)
|
||||
const basicFilter = (overrides.basic_filter ?? {}) as Record<string, any>
|
||||
const entrySignals = (overrides.entry_signals ?? []) as string[]
|
||||
@@ -1864,6 +1870,18 @@ export function StrategyBacktest() {
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{/* 叠加策略: 子策略构成归因 */}
|
||||
{result.strategy_info.composite_children && result.strategy_info.composite_children.length > 0 && (
|
||||
<div className="flex items-center gap-1.5 flex-wrap">
|
||||
<Layers className="h-3 w-3 text-teal-400 shrink-0" />
|
||||
<span className="text-[10px] text-muted">叠加 {result.strategy_info.composite_children.length} 策略</span>
|
||||
{result.strategy_info.composite_children.map(c => (
|
||||
<span key={c.id} className="text-[9px] px-1.5 py-px rounded border border-teal-500/25 bg-teal-500/10 text-teal-400">
|
||||
{c.id}<span className="text-teal-400/60 ml-0.5">{(c.weight * 100).toFixed(0)}%</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{result.strategy_info.stop_loss != null && (
|
||||
<span className="text-[10px] text-secondary">止损 {fmtPct(result.strategy_info.stop_loss)}</span>
|
||||
)}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { StockPanel } from '@/components/StockPanel'
|
||||
import type { ChartPriceLine, ChartRange } from '@/components/EChartsCandlestick'
|
||||
import type { StrategyBacktestTrade } from '@/lib/api'
|
||||
import { fmtPct, fmtPrice, priceColorClass } from '@/lib/format'
|
||||
import { useDialogBackdrop } from '@/lib/useDialogBackdrop'
|
||||
|
||||
interface Props {
|
||||
trade: StrategyBacktestTrade | null
|
||||
@@ -34,6 +35,7 @@ function fmtSignedMoney(v: number | null | undefined): string {
|
||||
|
||||
export function TradeKlineModal({ trade, onClose }: Props) {
|
||||
const [showIntraday, setShowIntraday] = useState(false)
|
||||
const backdrop = useDialogBackdrop(onClose)
|
||||
|
||||
useEffect(() => {
|
||||
if (!trade) return
|
||||
@@ -98,7 +100,7 @@ export function TradeKlineModal({ trade, onClose }: Props) {
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
className="absolute inset-0 bg-black/60 backdrop-blur-sm"
|
||||
onClick={onClose}
|
||||
{...backdrop}
|
||||
/>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.95, y: 12 }}
|
||||
|
||||
Reference in New Issue
Block a user