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:
@@ -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__}"
|
||||
)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user