Files
qlib/backend/app/application/services/factor_catalog.py
T
Simon 2e90f3eeac 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。
2026-10-01 16:33:32 +08:00

147 lines
5.9 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""因子目录用例:注册表投影 + 参数化实例的新建/停用(M7.1 起,2026-10 参数化)。
## 目录与代码的分工(这是本模块的核心规矩)
- **能不能算** = 代码注册表(`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 (
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 _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(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