字段库(本次新增的表与接口): - `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。
158 lines
6.5 KiB
Python
158 lines
6.5 KiB
Python
"""因子目录 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)
|