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
@@ -1,21 +1,147 @@
"""因子目录用例:把代码注册表因子 seed 进 DB(M7.1)。
"""因子目录用例:注册表投影 + 参数化实例的新建/停用(M7.1 起,2026-10 参数化)。
DB 为目录契约源;本服务在 /api/factors 首次读取为空时自动 seed(幂等),
后续代码新增因子也通过同一入口同步,保持目录与可计算因子一致。
## 目录与代码的分工(这是本模块的核心规矩)
- **能不能算** = 代码注册表(`quant/factors.py`)唯一决定。库里的行只要能解析出
`(模板, 参数)` 就算得出来;解析不出来的行(历史手工登记)保留但在目录里标
`resolvable=False`,引用时抛 `FactorError`(不假装支持)。
- **有哪些因子** = 目录(DB)。内置实例由代码投影进来;**参数化实例**(如
`momentum(window=90,direction=higher_is_better)`)由人在目录里创建 —— 参数写在名字里,
所以「同一个模板的多个参数版本」天然并存,且任何一个都冻结了自己的口径。
- **口径文案**(description/formula/brief/frequency/lookback/direction/requires)永远按
代码收敛:只要这行算得出来,它的文案就是引擎的文案。手改会被改回 —— 因为
「文档写一套、代码跑另一套」是本仓库明令禁止的;要改口径就改代码。
- **唯一人配的字段**是 `enabled`(是否出现在因子下拉里):只对参数化实例生效;
内置实例的开关同样由代码收敛(恒 True)。停用不影响已引用它的策略/归档解析 ——
历史口径不能被一个开关改义。
稳态(目录 == 代码投影)零写入,不会每次 GET 都刷库。
"""
from __future__ import annotations
from collections.abc import Mapping
from typing import Any
from app.domain.entities.factor import FactorDefinition
from app.domain.repositories.factor import FactorRepository
from app.quant.factors import list_factors
from app.quant.factors import (
FactorError,
build_factor_def,
canonical_key,
get_template,
is_resolvable,
list_factors,
resolve_factor,
)
# 「引擎口径」字段:一旦与代码不一致,目录就在撒谎,必须按代码改回。
# enabled 也在其中 —— 但它只对**注册表实例**收敛(见 sync_registry_factors)。
REGISTRY_FIELDS = (
"description",
"formula",
"brief",
"frequency",
"lookback",
"direction",
"requires",
"enabled",
)
# 参数化实例同样要收敛的字段(不含 enabled:开关是人配的)。
_ENGINE_FIELDS = tuple(f for f in REGISTRY_FIELDS if f != "enabled")
def seed_registry_factors(repo: FactorRepository, session) -> int:
"""把 quant/factors 注册表的元数据 upsert 进 factor_definition(幂等)。"""
defs = [FactorDefinition.from_registry_def(d) for d in list_factors()]
if not defs:
def _registry_names() -> set[str]:
return {d.name for d in list_factors()}
def _wanted_rows(current: list[FactorDefinition]) -> list[FactorDefinition]:
"""应然状态:内置实例按代码;能解析出来的参数化实例口径也按代码(开关保留)。"""
registry = _registry_names()
wanted = [FactorDefinition.from_registry_def(d) for d in list_factors()]
for row in current:
if row.name in registry:
continue # 内置实例已由上面的投影覆盖
try:
defn, _fn = resolve_factor(row.name)
except FactorError:
continue # 算不出来的手登记行:不碰(只有人写的备注,没有可收敛的引擎口径)
want = FactorDefinition.from_factor_def(defn, enabled=row.enabled)
want = want.model_copy(update={"created_at": row.created_at, "version": row.version})
wanted.append(want)
return wanted
def _differs(current: FactorDefinition | None, want: FactorDefinition, fields) -> bool:
if current is None:
return True
return any(getattr(current, name) != getattr(want, name) for name in fields)
def sync_registry_factors(repo: FactorRepository, session) -> int:
"""把代码投影进目录(幂等);返回本次写入的行数,稳态为 0。"""
current = repo.list()
by_name = {f.name: f for f in current}
registry = _registry_names()
stale: list[FactorDefinition] = []
for want in _wanted_rows(current):
row = by_name.get(want.name)
fields = REGISTRY_FIELDS if want.name in registry else _ENGINE_FIELDS
if row is None or _differs(row, want, fields):
stale.append(want)
if not stale:
return 0
n = repo.upsert_many(defs)
n = repo.upsert_many(stale)
session.commit()
return n
def create_parameterized_factor(
repo: FactorRepository,
session,
*,
template: str,
params: Mapping[str, Any] | None = None,
) -> FactorDefinition:
"""从模板 + 参数创建一个**新的参数化因子实例**(参数写在名字里,冻结口径)。
参数缺省项取模板默认值;越界/未知参数/重复的参数组合一律报错(不静默纠正)。
"""
tpl = get_template(template) # 未知模板 → FactorError
key = canonical_key(tpl, params or {}) # 越界/未知参数 → FactorError
if repo.get(key) is not None:
raise ValueError(f"该参数组合的因子已存在:{key}(参数相同不会重复创建)")
defn = build_factor_def(tpl, params or {}, name=key, source="custom")
entity = FactorDefinition.from_factor_def(defn, enabled=True)
repo.upsert_many([entity])
session.commit()
return entity
def set_factor_enabled(
repo: FactorRepository,
session,
*,
name: str,
enabled: bool,
) -> FactorDefinition:
"""启用/停用目录里的因子(只影响「能不能被选中」,不影响历史解析)。"""
if name in _registry_names():
raise ValueError(
f"「{name}」是代码注册表里的内置因子,开关由代码决定,不能在目录里停用;"
"如果要一个不同参数的版本,请从模板新建参数化因子。"
)
row = repo.get(name)
if row is None:
raise LookupError(f"因子 {name} 不在目录里")
if not is_resolvable(name):
raise ValueError(f"因子 {name} 引擎算不出来(未注册模板/参数非法),不能启用或停用")
updated = row.model_copy(update={"enabled": enabled})
repo.upsert_many([updated])
session.commit()
return updated
# 兼容旧名(语义相同:把注册表同步进目录)。
seed_registry_factors = sync_registry_factors