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