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:
@@ -78,6 +78,15 @@ class ComboService:
|
||||
daily=daily,
|
||||
eligibility_fns=eligibility_fns,
|
||||
)
|
||||
# 把价格口径写进 config_snapshot(与 ResearchService._annotate_price_basis 同口径),
|
||||
# 否则归档页/结果头读不到 adjust_mode,会误显示「不复权」(组合实际用的是公共配置的复权)。
|
||||
# 注意:不能覆盖整个 config_snapshot —— 引擎已把 ComboRunSpec 固化在里面(可复现依据)。
|
||||
mode = config.price_adjustment
|
||||
result.config_snapshot["price_basis"] = {
|
||||
"adjust_mode": mode,
|
||||
"price_basis": "adjust_factor" if mode != "none" else "raw_close",
|
||||
"execution_price_basis": "close_adj" if mode != "none" else "close_raw",
|
||||
}
|
||||
_stage(on_stage, "analysis")
|
||||
return _fill_names(result, self._last_stocks)
|
||||
|
||||
|
||||
@@ -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()
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user