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
@@ -0,0 +1,198 @@
"""字段库用例:目录 seed + 校验 + 增删改(2026-10)。
与 factor_catalog 同一套规矩:**DB 是目录契约源,代码注册表是可用性的唯一事实来源**。
读取时把「注册表有、库里没有」的内置字段补进去(只补不删、不覆盖用户改过的文案)。
单位的两层含义(2026-10 补)见 `quant/condition_fields.py`:``unit`` 存的是**界面单位**
(输入/显示用,可从注册表给的阶梯里选),引擎始终按**基准单位**存储与比较,
换算是提交/回显时按系数做的 —— 所以改单位不会让任何历史策略变义。
"""
from __future__ import annotations
from app.domain.entities.condition_field import ConditionField
from app.domain.repositories.condition_field import ConditionFieldRepository
from app.quant.condition_fields import (
GROUP_ORDER,
FieldDef,
available_fields,
curated_fields,
get_field,
reason_unsupported,
unit_allowed,
unit_options,
)
_SORT_BASE = 100
def _check_unit(name: str, unit: str) -> str:
"""界面单位必须落在注册表给的阶梯里(不允许自由文本 —— 见 AGENT §24)。
空串 = 保持基准单位。给出可用单位清单,用户不用猜为什么被拒。
"""
d = get_field(name)
base = d.unit if d else ""
if not unit.strip():
return base
if not unit_allowed(name, unit.strip()):
allowed = "、".join(u for u, _ in unit_options(name)) or "(无可用单位)"
raise ValueError(
f"字段「{name}」不支持单位「{unit.strip()}」:可选 {allowed}。"
"单位只能从这些里选,因为换算是按固定系数做的(引擎按基准单位比较)。"
)
return unit.strip()
def _sort_order(d: FieldDef) -> int:
"""同分组内保持注册表顺序(分组顺序 × 1000 + 组内序号)。"""
try:
g = GROUP_ORDER.index(d.group_name)
except ValueError:
g = len(GROUP_ORDER)
return g * 1000 + _SORT_BASE
def _from_def(d: FieldDef) -> ConditionField:
return ConditionField(
name=d.name,
label=d.label,
description=d.description,
kind=d.kind,
group_name=d.group_name,
unit=d.unit,
source="builtin",
enabled=True,
sort_order=_sort_order(d),
)
def sync_builtin_fields(repo: ConditionFieldRepository, session) -> int:
"""补齐缺失的**默认内置字段**(幂等;已存在的一律不动,用户改过的文案得以保留)。
只 seed `curated=True` 的那批:`curated=False` 的字段是「引擎支持但默认不进库」的
选项,留给用户在字段库里按需新增(见 `list_available`)—— 否则「新增字段」永远
无字段可选。
"""
existing = {f.name for f in repo.list()}
missing = [_from_def(d) for d in curated_fields() if d.name not in existing]
added = repo.insert_missing(missing)
if added:
session.commit()
return added
def list_fields(repo: ConditionFieldRepository, session, include_disabled: bool = True):
"""字段库列表(首次读取自动 seed)。按分组/注册表顺序排序。"""
items = repo.list()
if not items:
sync_builtin_fields(repo, session)
items = repo.list()
# 代码里新注册的默认字段也要补上:按差集触发,稳态零写入
if {d.name for d in curated_fields()} - {f.name for f in items}:
sync_builtin_fields(repo, session)
items = repo.list()
if not include_disabled:
items = [i for i in items if i.enabled]
return items
def list_available(repo: ConditionFieldRepository, session):
"""引擎支持但尚未进目录的字段(「新增字段」的可选项)。"""
return available_fields({f.name for f in repo.list()})
def create_field(
repo: ConditionFieldRepository,
session,
*,
name: str,
label: str = "",
description: str = "",
group_name: str = "",
unit: str = "",
enabled: bool = True,
) -> ConditionField:
"""新增自定义字段。
校验顺序有意如此:先看引擎能不能算(不能算就 422,绝不放行)→ 再看是否已存在。
这样用户拿到的是「这个字段引擎算不出来」而不是含糊的「已存在」。
"""
key = name.strip()
reason = reason_unsupported(key)
if reason:
raise ValueError(reason)
if repo.get(key) is not None:
raise ValueError(f"字段「{key}」已在字段库中:直接编辑它,或给它改个中文名/含义即可")
d = get_field(key)
assert d is not None # reason_unsupported 为空 ⇒ 注册表必有此字段
chosen = _check_unit(key, unit)
item = ConditionField(
name=key,
label=(label.strip() or d.label),
description=(description.strip() or d.description),
kind=d.kind, # 类型来自引擎,不接受调用方声明
group_name=(group_name.strip() or d.group_name),
unit=chosen,
source="custom",
enabled=enabled,
sort_order=_sort_order(d),
)
saved = repo.save(item)
session.commit()
return saved
def update_field(
repo: ConditionFieldRepository,
session,
name: str,
*,
label: str | None = None,
description: str | None = None,
group_name: str | None = None,
unit: str | None = None,
enabled: bool | None = None,
) -> ConditionField:
"""编辑字段文案/分组/**界面单位**/启用状态。
``name`` / ``kind`` / ``source`` 不可改:前者是引擎字段名,后两者是引擎事实与
条目来历 —— 允许改会让「字段库」与引擎脱节(§24 不做假支持)。
``unit`` 可改但**只能在注册表给的阶梯里选**(如 万元 ⇄ 亿元):它是界面单位,
提交/回显按固定系数换算,引擎始终用基准单位 —— 所以改它不会让历史策略变义。
"""
item = repo.get(name)
if item is None:
raise KeyError(name)
patch = item.model_copy(
update={
k: v
for k, v in {
"label": label,
"description": description,
"group_name": group_name,
"unit": _check_unit(name, unit) if unit is not None else None,
"enabled": enabled,
}.items()
if v is not None
}
)
if not patch.label.strip():
raise ValueError("中文名不能为空(下拉里要显示它)")
saved = repo.save(patch)
session.commit()
return saved
def delete_field(repo: ConditionFieldRepository, session, name: str) -> None:
"""删除自定义字段。内置字段不允许删除 —— 删了下次 seed 又会补回来,只会让人困惑。"""
item = repo.get(name)
if item is None:
raise KeyError(name)
if item.source == "builtin":
raise ValueError(
f"「{name}」是内置字段,不能删除(删掉下次读取也会自动补回)。"
"如果不想在条件里看到它,请改为「停用」。"
)
repo.delete(name)
session.commit()