字段库(本次新增的表与接口): - `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。
177 lines
6.9 KiB
Python
177 lines
6.9 KiB
Python
"""字段库 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} |