Files
qlib/backend/app/api/condition_fields.py
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

177 lines
6.9 KiB
Python
Raw Permalink 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.
"""字段库 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}