feat(backend): 字段库(condition_field)+ 因子参数化(模板/受控参数)+ 单位换算底座

字段库(本次新增的表与接口):
- `condition_field` 表 + `/api/condition-fields`:中文名/说明可编辑、可停用;
  `kind`/单位阶梯/`base_unit` 由代码注册表收敛(改类型 422,伪字段 422,
  越界单位 422),停用的字段不再进条件下拉,但既有策略仍按名字解析。
- 说明书里的数值条件按字段注册表补**基准单位**后缀(字段间比较不加,不猜单位)。

因子参数化(键即身份,冻结口径):
- 模板 + 参数注册表(`quant/factors.py`):`ParamSpec`(类型/范围/枚举/默认值/说明)+
  `FactorTemplate`(公式/依赖列/参数);规范键把**全部**参数写进名字,如
  `momentum(window=90,direction=lower_is_better)`,所以改参数 = 新建一个身份,
  旧因子/既有策略/已归档实验都不变义;`momentum(window=90)`(缺参数)明确拒绝 ——
  缺项要靠模板默认值补齐,而默认值是可改的代码细节,一旦改动会追溯性改义。
- 参数只在受控范围内取值(窗口 2~500、方向二选一),越界/未知模板/多给参数一律 422
  并列出允许范围,不静默截断、不悄悄取默认值;内置实例的启用开关由代码决定(422)。
- `/api/factors` 暴露 `template`/`params`/`param_specs`/`label`/`source`/`enabled`/
  `resolvable`;新增 `/api/factors/templates`、`POST /api/factors`、`PATCH /api/factors`;
  `get_factor = resolve_factor` 兼容全部旧调用点,参数化键也是一等条件字段。
- 迁移链:c5d6(存量策略陈旧说明重算)→ d6e7(condition_field)→ a7c1
  (factor_definition.enabled + name varchar(128))。

测试:新增 test_condition_fields.py / test_factor_params.py;全量 pytest 500 passed。
This commit is contained in:
Simon
2026-10-01 16:33:32 +08:00
parent 40bd603b44
commit 2e90f3eeac
39 changed files with 3280 additions and 244 deletions
+23 -5
View File
@@ -30,6 +30,7 @@ from app.domain.entities.market import (
)
from app.domain.entities.research import ResearchSpec
from app.domain.entities.strategy import SelectionStrategy, StrategyDefinition
from app.quant.condition_fields import get_field
from app.quant.factors import FactorDef, FactorError, get_factor
# 选股策略(SelectionStrategy)不含回测参数,走 _describe_selection_only 专用分支;
@@ -137,10 +138,12 @@ def _describe_selection_only(st, factor_meta) -> StrategyDoc:
" ⚠ 持仓数量 / 持仓天数区间 / 调仓时机 / 起始资金 / 费率 / 复权口径 / 回测区间"
"均不在本策略内 —— 它们在「回测组合」中指定,运行时与公共配置合并。"
)
# 步骤文案**不带序号**:前端把它渲染进 <ol>(StrategyDocCard),编号由列表提供;
# 后端再写一遍「1. 2. 3.」会渲染成「1. 1. …」双重编号(2026-10 修正)。
steps = [
"1. 按股票池口径筛出候选 universe(市场 / 剔 ST / 上市天数 / 指数成分)。",
"2." + (" 逐条求值过滤条件(AND),剔除不满足者。" if st.conditions else " (未设过滤条件,候选 = universe。)"),
"3. 对剩余股票按上述因子打分并降序排列 → 得到候选排名(TopN 在回测组合里截取)。",
"按股票池口径筛出候选 universe(市场 / 剔 ST / 上市天数 / 指数成分)。",
"逐条求值过滤条件(AND),剔除不满足者。" if st.conditions else "未设过滤条件,候选 = universe。",
"对剩余股票按上述因子打分并降序排列 → 得到候选排名(TopN 在回测组合里截取)。",
]
warnings.append(
"本说明只覆盖选股口径;回测的资金/持仓/调仓/成本/区间由「回测组合」+「公共配置」决定,"
@@ -187,13 +190,25 @@ def _describe_factors_from_specs(factor_specs, factor_meta, warnings) -> list[di
return out
def _unit_suffix(field: str, cond) -> str:
"""字面量条件的**基准单位**后缀(引擎就是按它比较的)。
字段间比较(ref)不加:两侧同一单位,写出来只会误导。字段不在注册表里 → 不加,
宁可少写也不猜单位(猜错就是静默的口径错误)。
"""
if getattr(cond, "ref", None) is not None:
return ""
d = get_field(field)
return f" {d.unit}" if d is not None and d.unit else ""
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}")
lines.append(f"{c.field} {op} {right}{_unit_suffix(c.field, c)}")
return lines
@@ -583,12 +598,15 @@ def _render_condition(cond) -> str:
op = _OP_TEXT.get(cond.op, cond.op)
if cond.ref is not None:
right = cond.ref
suffix = ""
elif cond.op in ("in", "not_in"):
items = cond.value if isinstance(cond.value, Sequence) else [cond.value]
right = "[" + ", ".join(_fmt_value(v) for v in items) + "]"
suffix = _unit_suffix(cond.field, cond)
else:
right = _fmt_value(cond.value)
return f"{cond.field} {op} {right}"
suffix = _unit_suffix(cond.field, cond)
return f"{cond.field} {op} {right}{suffix}"
def _is_known_field(field: str) -> bool: