"""因子目录 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)