feat(backend): 策略库重构为「选股策略 + 公共配置 + 回测组合」三件套

按用户目标把原来「一个策略 = 全套参数」拆开(已确认的设计决策):
- 公共配置 GlobalConfig(全局唯一):佣金/印花税/滑点/最低佣金/复权口径/基准
- 选股策略 SelectionStrategy(原 StrategyDefinition 改名):只剩股票池+因子+条件,
  不再持有 selection/rebalance/costs/portfolio/区间/资金
- 回测组合 BacktestCombo:引用若干选股策略 + 回测时才定的参数
  (起始资金、持仓数 N、持仓天数区间 [Tmin,Tmax]、调仓时机 日/周/月、区间)

引擎(app/quant/combo_engine.py,新增):
- 多策略打分 = 并集 + Borda 秩和(各策略 1/名次 求和;不假设不同策略分值可比,
  能容纳各策略股票池不同);抽出纯函数 borda_combine 便于单测
- 持仓天数区间 [Tmin,Tmax]:Tmax **每个交易日**强制了结(安全阀,月频下也不超期);
  Tmin 仅在调仓日保护(掉出 TopN 但未满 Tmin 暂留,防频繁换手);调仓日为增量调仓
  (只卖超期/掉队且满 Tmin 的,从 TopN 补买至 N 只,不主动减持以尊重 Tmin)
- 调仓时机 daily/weekly/monthly(local_engine.rebalance_dates 新增日频分支)
- 产出与旧 runner 同构的 BacktestResult,前端可视化无需改动;config_snapshot 固化
  ComboRunSpec(组合+当时各策略定义+当时成本/复权)保证可复现

数据层:
- 新表 global_config(默认行:万三/hfq/最低佣金5元)、backtest_combo
- 迁移 b4c5d6e7f8a9:建两表 + 把存量 strategy.config_json 的回测参数键剥掉、
  spec_type 收敛为 selection(已在真实 MariaDB 验证:STG-16BFBF08 清洗后只剩
  universe/factors/conditions)
- 仓储 SqlAlchemyGlobalConfigRepository / SqlAlchemyComboRepository + Protocol

API:
- /api/config GET/PUT;/api/combos CRUD + /{id}/run + /run(kind=combo 异步 Job)
- job_executor 新增 combo 分支:取齐策略+读公共配置→ComboService.run,归档 kind
  记 backtest(结果结构相同)
- /api/strategies 切到 SelectionStrategy,移除已废弃的 /{id}/expand
- strategy_doc.describe_strategy 支持 SelectionStrategy(只讲「怎么选」,如实声明
  资金/持仓/调仓/成本/区间在回测组合里定)

旧的 ResearchSpec + /api/backtests 保留(因子测试与既有契约自检仍用),
作为底层 escape hatch;用户产品路径改为回测组合。

测试:新增 test_combo_engine(6)/test_combo_service(3)/test_combo_api(5),
改写 test_strategies/test_strategy_doc 适配新模型。全量 403 passed(原 388)。
This commit is contained in:
Simon
2026-09-30 21:43:28 +08:00
parent 50a1030afa
commit 40bd603b44
25 changed files with 2250 additions and 174 deletions
+117 -18
View File
@@ -1,4 +1,4 @@
"""策略说明书生成器:ResearchSpec / StrategyDefinition → 一句话说明 + 计算公式 + 步骤 + 注意事项。
"""策略说明书生成器:ResearchSpec(回测)/ SelectionStrategy(选股策略)→ 说明 + 公式 + 步骤 + 注意事项。
纯函数模块:无 IO、无 DB、不调用引擎,因而可被 API 复用(含**未保存**的策略即时预览)
并被单测直接覆盖。
@@ -29,12 +29,12 @@ from app.domain.entities.market import (
DAILY_BASIC_NUMERIC_FIELDS,
)
from app.domain.entities.research import ResearchSpec
from app.domain.entities.strategy import StrategyDefinition
from app.domain.entities.strategy import SelectionStrategy, StrategyDefinition
from app.quant.factors import FactorDef, FactorError, get_factor
# StrategyDefinition 没有回测区间字段(区间在回测时补全),但 to_research_spec 的
# period 是必填的 —— 用一个不可能被误读为真实区间的占位区间满足校验,
# 真正展示时以 `period_known=False` 走占位文案(不抛错、也不假装知道区间)。
# 选股策略(SelectionStrategy)不含回测参数,走 _describe_selection_only 专用分支;
# 以下占位常量仅保留给历史 ResearchSpec 路径兼容(当前 describe_strategy 不再对
# SelectionStrategy 调用 to_research_spec)。
_PLACEHOLDER_PERIOD: tuple[date, date] = (date(1900, 1, 1), date(1900, 1, 2))
_PERIOD_UNKNOWN_TEXT = "(回测区间未指定:策略定义本身不含 period,展开回测后才确定)"
@@ -75,21 +75,25 @@ class StrategyDoc(BaseModel):
def describe_strategy(
spec: ResearchSpec | StrategyDefinition,
spec: ResearchSpec | SelectionStrategy,
*,
factor_meta: Mapping[str, FactorDef] | None = None,
) -> StrategyDoc:
"""生成策略说明书。
`spec`:ResearchSpec(回测页参数,含 period)或 StrategyDefinition(策略库资产,
无 period)。后者经 `to_research_spec(period=占位)` 展开 —— 区间缺失只影响展示文案
(走 `_PERIOD_UNKNOWN_TEXT`),不影响其余推导,更不抛错。
`spec`:ResearchSpec(回测页参数,含 period/costs/selection)或 SelectionStrategy
(策略库资产,只有选股条件)。后者走 `_describe_selection_only`,只讲「怎么选」,
如实声明资金/持仓/调仓/成本/区间不在策略内(在回测组合里定)。
`factor_meta`:可选的因子元数据覆盖/补充(如内置注册表之外的实验因子)。
查找顺序为 `factor_meta` → `app.quant.factors.get_factor`;两处都没有则记入
warnings(说明该因子的含义/公式/方向未知,执行期会直接报错)。
"""
rspec, period_text, period_known = _coerce_spec(spec)
coerced = _coerce_spec(spec)
# 选股策略(无回测参数)走专用说明;回测 spec 走原有完整路径
if isinstance(coerced, (SelectionStrategy, StrategyDefinition)) and not isinstance(coerced, ResearchSpec):
return _describe_selection_only(coerced, factor_meta)
rspec, period_text, period_known = coerced
warnings: list[str] = []
factors = _describe_factors(rspec, factor_meta, warnings)
conditions = _describe_conditions(rspec, warnings)
@@ -101,22 +105,117 @@ def describe_strategy(
return StrategyDoc(summary=summary, formula=formula, steps=steps, warnings=warnings)
def _describe_selection_only(st, factor_meta) -> StrategyDoc:
"""选股策略的说明:只讲「怎么选」(股票池 + 因子 + 条件),不涉及回测执行参数。
资金 / 持仓数 / 持仓时间 / 调仓时机 / 费率 / 复权 / 区间都不属于选股策略,
在回测组合里才确定 —— 这里如实声明,避免读者以为策略自带这些口径。
"""
warnings: list[str] = []
factors = _describe_factors_from_specs(st.factors, factor_meta, warnings)
pool = _universe_text(st.universe)
cond_lines = _condition_lines(st.conditions, warnings) if st.conditions else []
factor_names = "、".join(f["name"] for f in factors) or "(无)"
pool_desc = _short_pool(st.universe)
summary = (
f"选股策略「{st.name}」:在{pool_desc}内"
+ (f"先通过 {len(st.conditions)} 条过滤条件,再" if st.conditions else "")
+ f"按因子({factor_names})打分排序,供回测组合取 TopN 持仓。"
)
formula_lines = [
"【选股口径】(仅定义「怎么选」,不含回测执行参数)",
f" 股票池:{pool}",
" 打分:score = Σ 权重 × 因子值(截面 z-score 标准化后加权,越大越优先)",
]
for f in factors:
formula_lines.append(f" · {f['line']}")
if cond_lines:
formula_lines.append(" 过滤条件(AND,先于打分执行):")
formula_lines.extend(f" · {ln}" for ln in cond_lines)
formula_lines.append(
" ⚠ 持仓数量 / 持仓天数区间 / 调仓时机 / 起始资金 / 费率 / 复权口径 / 回测区间"
"均不在本策略内 —— 它们在「回测组合」中指定,运行时与公共配置合并。"
)
steps = [
"1. 按股票池口径筛出候选 universe(市场 / 剔 ST / 上市天数 / 指数成分)。",
"2." + (" 逐条求值过滤条件(AND),剔除不满足者。" if st.conditions else " (未设过滤条件,候选 = universe。)"),
"3. 对剩余股票按上述因子打分并降序排列 → 得到候选排名(TopN 在回测组合里截取)。",
]
warnings.append(
"本说明只覆盖选股口径;回测的资金/持仓/调仓/成本/区间由「回测组合」+「公共配置」决定,"
"此处无法给出收益公式与成交口径。"
)
return StrategyDoc(summary=summary, formula="\n".join(formula_lines), steps=steps, warnings=warnings)
def _short_pool(universe) -> str:
parts = []
if universe.index_code:
parts.append(f"{universe.index_code} 成分股")
elif universe.symbols:
parts.append(f"指定的 {len(universe.symbols)} 只白名单股票")
else:
parts.append("全市场 A 股")
if universe.exclude_st:
parts.append("剔除 ST ")
return "、".join(parts)
def _describe_factors_from_specs(factor_specs, factor_meta, warnings) -> list[dict]:
"""从 FactorSpec 列表生成 [{label, line}](复用既有因子元数据查找逻辑)。"""
out: list[dict] = []
for fs in factor_specs:
meta = _lookup_factor(fs.name, factor_meta)
label = fs.name
direction = "越高越好"
formula_hint = ""
if meta is None:
warnings.append(
f"因子 {fs.name} 未在注册表中找到元数据:含义/公式/方向未知,"
"执行期会报错;说明里只能给出名字与权重。"
)
else:
label = meta.brief or meta.description or fs.name
direction = "越低越好" if meta.direction == "lower_is_better" else "越高越好"
formula_hint = f"({meta.formula})" if meta.formula else ""
out.append({
"name": fs.name,
"label": label if label != fs.name else fs.name,
"line": f"{fs.name}{formula_hint},权重 {fs.weight},{direction}",
})
return out
def _condition_lines(conditions, warnings) -> list[str]:
"""把 ConditionSpec 列表渲染成可读行(复用既有字段域校验逻辑)。"""
lines: list[str] = []
for c in conditions:
op = _OP_TEXT.get(c.op, c.op)
right = f"字段 {c.ref}" if c.ref else f"{c.value}"
lines.append(f"{c.field} {op} {right}")
return lines
# ---------- 入参归一化 ----------
def _coerce_spec(spec) -> tuple[ResearchSpec, str, bool]:
"""归一化为 (ResearchSpec, 区间展示文本, 区间是否已知)。
def _coerce_spec(spec):
"""归一化输入。
StrategyDefinition 无 period:按需求用占位区间展开并如实标记「区间未知」,
而不是抛错(策略库列表/详情页也要能看说明)。
- ResearchSpec(回测页参数,含 period/costs/selection)→ (ResearchSpec, 区间文本, True),
走完整回测说明书路径。
- SelectionStrategy(策略库资产,**只有选股条件**,无 period/costs/selection)→
直接返回该对象,由 `describe_selection_strategy` 生成「选股口径」说明。
重构后策略库不再持有回测执行参数,故不能也不应假装展开成 ResearchSpec。
"""
if isinstance(spec, StrategyDefinition):
return spec.to_research_spec(period=_PLACEHOLDER_PERIOD), _PERIOD_UNKNOWN_TEXT, False
if isinstance(spec, ResearchSpec):
start, end = spec.period
return spec, f"{start.isoformat()} ~ {end.isoformat()}", True
return (spec, f"{start.isoformat()} ~ {end.isoformat()}", True)
if isinstance(spec, (SelectionStrategy, StrategyDefinition)):
return spec # 选股策略:单独处理
raise TypeError(
"describe_strategy 只接受 ResearchSpec 或 StrategyDefinition,"
"describe_strategy 只接受 ResearchSpec 或 SelectionStrategy,"
f"收到 {type(spec).__name__}"
)