"""字段库 API:/api/condition-fields(2026-10)。 GET /api/condition-fields 字段库列表(首次读取自动 seed 内置字段) GET /api/condition-fields/available 引擎支持但尚未进库的字段(「新增」可选项) POST /api/condition-fields 新增自定义字段(必须指向引擎真能算的字段) PUT /api/condition-fields/{name} 改中文名/含义/分组/单位/启用状态 DELETE /api/condition-fields/{name} 删除自定义字段(内置字段只能停用) 设计要点(AGENT.md §24 不假装支持):字段能不能算由 `quant.condition_fields` 对着引擎域 判定;库里登记不出来的字段会被拒绝(422),否则用户会建出「永远选不出股票」的空策略。 """ from __future__ import annotations from fastapi import APIRouter, HTTPException from pydantic import BaseModel, ConfigDict, Field from app.api.deps import ConditionFieldRepoDep, DbSession from app.application.services import condition_field_catalog as svc from app.domain.entities.condition_field import ConditionField from app.quant.condition_fields import FieldDef, get_field, unit_options router = APIRouter(prefix="/condition-fields", tags=["condition-fields"]) class UnitOption(BaseModel): """可选**界面单位** + 它到**基准单位**的换算系数(提交前 ×factor,回显时 ÷factor)。 ``factor`` 由注册表给出(如 总市值:万元=1、亿元=10000),前端据此换算。 """ model_config = ConfigDict(extra="forbid") unit: str factor: float class ConditionFieldOut(ConditionField): """字段库响应:目录字段 + **从注册表派生**的单位信息。 ``base_unit`` / ``units`` 不落库:它们是引擎口径(注册表)的投影,若存进表里就会 和代码漂移。``unit`` 才是库里存的那一项(当前界面单位,必须落在 ``units`` 里)。 """ base_unit: str = "" units: list[UnitOption] = Field(default_factory=list) @classmethod def from_item(cls, item: ConditionField) -> ConditionFieldOut: d = get_field(item.name) return cls( **item.model_dump(exclude={"ops"}), # ops 是 computed_field,不参与构造 base_unit=(d.unit if d else item.unit), units=[UnitOption(unit=u, factor=f) for u, f in unit_options(item.name)], ) class FieldOption(BaseModel): """「可新增字段」的建议项:来自代码注册表,附带默认中文名与含义。""" model_config = ConfigDict(extra="forbid") name: str label: str description: str kind: str group_name: str unit: str = "" units: list[UnitOption] = Field(default_factory=list) @classmethod def from_def(cls, d: FieldDef) -> FieldOption: return cls( name=d.name, label=d.label, description=d.description, kind=d.kind, group_name=d.group_name, unit=d.unit, units=[UnitOption(unit=u, factor=f) for u, f in d.unit_options], ) class ConditionFieldCreate(BaseModel): """新增请求。kind 不收:类型是引擎事实,由服务端按注册表填。""" model_config = ConfigDict(extra="forbid") name: str = Field(min_length=1, max_length=64) label: str = Field(default="", max_length=64) description: str = Field(default="", max_length=500) group_name: str = Field(default="", max_length=32) unit: str = Field(default="", max_length=16) enabled: bool = True class ConditionFieldUpdate(BaseModel): """编辑请求。name/kind/source 有意不可改(name 是引擎字段名,改了就换字段了)。""" model_config = ConfigDict(extra="forbid") label: str | None = Field(default=None, max_length=64) description: str | None = Field(default=None, max_length=500) group_name: str | None = Field(default=None, max_length=32) unit: str | None = Field(default=None, max_length=16) enabled: bool | None = None @router.get("", response_model=list[ConditionFieldOut], summary="字段库列表") def list_condition_fields( repo: ConditionFieldRepoDep, session: DbSession, include_disabled: bool = True ) -> list[ConditionFieldOut]: """读字段库;缺失的内置字段当场补齐(幂等,稳态零写入)。 `include_disabled=false` 供条件编辑器使用(只列启用项);字段库管理页用默认值 (列出全部,含停用项,否则用户没法把停用的字段再打开)。 """ return [ConditionFieldOut.from_item(f) for f in svc.list_fields(repo, session, include_disabled=include_disabled)] @router.get("/available", response_model=list[FieldOption], summary="可新增的字段") def list_available_fields(repo: ConditionFieldRepoDep, session: DbSession) -> list[FieldOption]: return [FieldOption.from_def(d) for d in svc.list_available(repo, session)] @router.post("", response_model=ConditionFieldOut, summary="新增自定义字段") def create_condition_field( body: ConditionFieldCreate, repo: ConditionFieldRepoDep, session: DbSession ) -> ConditionFieldOut: try: saved = svc.create_field( repo, session, name=body.name, label=body.label, description=body.description, group_name=body.group_name, unit=body.unit, enabled=body.enabled, ) except ValueError as exc: # 422:语义是「引擎算不出来 / 已在库里 / 单位不在可选范围」,属于请求内容不可处理 raise HTTPException(status_code=422, detail=str(exc)) from exc return ConditionFieldOut.from_item(saved) @router.put("/{name}", response_model=ConditionFieldOut, summary="编辑字段(中文名/含义/单位/启用)") def update_condition_field( name: str, body: ConditionFieldUpdate, repo: ConditionFieldRepoDep, session: DbSession ) -> ConditionFieldOut: try: saved = svc.update_field( repo, session, name, label=body.label, description=body.description, group_name=body.group_name, unit=body.unit, enabled=body.enabled, ) except KeyError as exc: raise HTTPException(status_code=404, detail=f"字段 {name} 不存在") from exc except ValueError as exc: raise HTTPException(status_code=422, detail=str(exc)) from exc return ConditionFieldOut.from_item(saved) @router.delete("/{name}", summary="删除自定义字段(内置只能停用)") def delete_condition_field(name: str, repo: ConditionFieldRepoDep, session: DbSession) -> dict: try: svc.delete_field(repo, session, name) except KeyError as exc: raise HTTPException(status_code=404, detail=f"字段 {name} 不存在") from exc except ValueError as exc: raise HTTPException(status_code=400, detail=str(exc)) from exc return {"deleted": name}