Files
qlib/backend/app/application/services/condition_field_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

198 lines
7.1 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.
"""字段库用例:目录 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()