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

158 lines
6.5 KiB
Python
Raw 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/factors(M7.1 起读 DB,2026-10 支持参数化实例)。
## 目录的三条规则(详见 application/services/factor_catalog.py)
① 注册表有、库里没有 → 补齐(历史 bug:表非空后新因子永远进不了目录)。
② 能算出来的行,口径字段按代码改回 —— 目录不允许与引擎口径不一致(手改会被纠正)。
③ 库里多出来的行保留(只补不删),标 `resolvable` 告知是否算得出来。
## 参数化(本文件新增的部分)
- **暴露**:每个因子返回 `template` / `params` / `param_specs`(可编辑参数与允许范围)/
`label`(中文名含参数)/ `source` / `resolvable` / `enabled`,界面据此渲染参数表与表单。
- **新建**:`POST /api/factors` 传 `{template, params}` → 生成参数化实例
`momentum(window=90,direction=higher_is_better)`。参数写在名字里,所以它**冻结**了自己的
口径:以后无论谁再改参数,既有策略/归档按各自名字里的参数计算,不会变义。
- **停用**:`PATCH /api/factors` 传 `{name, enabled}`。名字里有括号/等号/逗号,放路径里
会被各种代理折腾,所以放在 body 里。
- 越界参数、未知模板、重复的参数组合 → **422**,并在 detail 里说明允许范围。
"""
from __future__ import annotations
from typing import Any
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel, Field
from app.api.deps import DbSession, FactorRepoDep
from app.application.services.factor_catalog import (
create_parameterized_factor,
set_factor_enabled,
sync_registry_factors,
)
from app.domain.entities.factor import FactorDefinition, FactorParam
from app.quant.factors import FactorError, list_templates, resolve_factor
router = APIRouter(prefix="/factors", tags=["factors"])
class FactorTemplateOut(BaseModel):
"""模板(算法家族):可编辑参数 + 默认值 + 口径说明,供「新建参数化因子」表单。"""
name: str
label: str
description: str
formula: str
brief: str
requires: list[str] = Field(default_factory=list)
frequency: str = "daily"
direction_default: str = "higher_is_better"
param_specs: list[FactorParam] = Field(default_factory=list)
defaults: dict[str, Any] = Field(default_factory=dict)
instances: list[str] = Field(default_factory=list) # 该模板已有的内置实例名
class FactorCreate(BaseModel):
"""新建参数化因子:模板 + 参数(缺省项取模板默认值)。"""
template: str
params: dict[str, Any] = Field(default_factory=dict)
class FactorPatch(BaseModel):
"""开关因子(name 放 body:名字里有括号/等号/逗号,不适合放路径)。"""
name: str
enabled: bool
def _template_out(tpl) -> FactorTemplateOut:
return FactorTemplateOut(
name=tpl.name,
label=tpl.label,
description=tpl.description,
formula=tpl.formula,
brief=tpl.brief,
requires=list(tpl.requires),
frequency=tpl.frequency,
direction_default=tpl.direction_default,
param_specs=[FactorParam.from_spec(s) for s in tpl.specs()],
defaults=tpl.defaults(),
instances=[name for name, _params in tpl.instances],
)
def _enrich(row: FactorDefinition) -> FactorDefinition:
"""把库里的行补成「引擎口径的投影」:能算出来的行一律以引擎为准。
- 算得出来 → description/formula/brief/frequency/lookback/direction/requires/
template/params/param_specs/label/source 全部取自引擎(目录永不撒谎);
`enabled` 仍是库里的人配值。
- 算不出来(历史手工登记行)→ 原样返回并标 `resolvable=False`,界面显示为不可用。
"""
try:
defn, _fn = resolve_factor(row.name)
except FactorError:
return row.model_copy(update={"resolvable": False, "label": row.name})
return FactorDefinition.from_factor_def(defn, enabled=row.enabled).model_copy(
update={"created_at": row.created_at, "version": row.version}
)
@router.get("", summary="因子目录(注册表投影 + 参数化实例,含可编辑参数)")
def list_factor_catalog(factor_repo: FactorRepoDep, session: DbSession) -> list[FactorDefinition]:
"""读目录(先做幂等同步,稳态零写入),每行补上参数化视图。
前端据此渲染:因子名 / 中文名含参数 / 窗口 · 方向等真实参数 / 是否可当过滤条件。
"""
sync_registry_factors(factor_repo, session)
return [_enrich(row) for row in factor_repo.list()]
@router.get("/templates", summary="因子模板:可编辑参数与允许范围")
def list_factor_templates() -> list[FactorTemplateOut]:
"""全部模板(动量 / 波动率 / 量比 / 乖离 / 反转 / 接近新高 / 股息率…)。
每个模板给出 `param_specs`(参数名、类型、允许范围/枚举、默认值、说明)——
界面据此渲染受控表单:**参数只在给定范围内选/填**,越界在 API 层就被拒。
"""
return [_template_out(tpl) for tpl in list_templates()]
@router.post("", status_code=201, summary="新建参数化因子(模板 + 参数)")
def create_factor(
payload: FactorCreate,
factor_repo: FactorRepoDep,
session: DbSession,
) -> FactorDefinition:
"""从模板派生一个新的参数化因子实例;参数写进名字,因此口径被冻结。
重复的参数组合不会重复创建(409 语义由 422 承载并给出已有名字,前端直接提示即可)。
"""
try:
row = create_parameterized_factor(
factor_repo, session, template=payload.template, params=payload.params
)
except FactorError as exc:
raise HTTPException(status_code=422, detail=str(exc)) from exc
except ValueError as exc:
raise HTTPException(status_code=422, detail=str(exc)) from exc
return _enrich(row)
@router.patch("", summary="启用/停用因子(只影响能否被选中)")
def patch_factor(
payload: FactorPatch,
factor_repo: FactorRepoDep,
session: DbSession,
) -> FactorDefinition:
"""停用只把因子从下拉里拿掉:既有策略/归档仍按名字解析(历史不变义)。"""
try:
row = set_factor_enabled(factor_repo, session, name=payload.name, enabled=payload.enabled)
except LookupError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
except ValueError as exc:
raise HTTPException(status_code=422, detail=str(exc)) from exc
return _enrich(row)