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