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。
This commit is contained in:
@@ -17,6 +17,8 @@ POST /api/combos/run 提交临时组合(不保存)为异步 Job
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from fastapi import APIRouter, BackgroundTasks, HTTPException
|
||||
|
||||
from app.api.deps import ComboRepoDep, DbSession, StrategyRepoDep
|
||||
@@ -108,7 +110,7 @@ def _submit_combo_job(
|
||||
_ensure_strategies_exist(combo, strategy_repo)
|
||||
job = JobRecord(
|
||||
id=new_id("JOB"), kind="combo", status=JobStatus.QUEUED,
|
||||
spec_json=combo.model_dump_json(),
|
||||
spec_json=combo.model_dump_json(), created_at=datetime.now(),
|
||||
)
|
||||
SqlAlchemyJobRepository(session).create(job)
|
||||
session.commit()
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
"""字段库 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}
|
||||
@@ -16,6 +16,7 @@ from app.application.services.selection_service import SelectionService
|
||||
from app.application.services.signal_service import SignalService
|
||||
from app.domain.repositories.combo import ComboRepository, GlobalConfigRepository
|
||||
from app.domain.repositories.composite import CompositeRepository
|
||||
from app.domain.repositories.condition_field import ConditionFieldRepository
|
||||
from app.domain.repositories.factor import FactorRepository
|
||||
from app.domain.repositories.index import IndexConstituentRepository
|
||||
from app.domain.repositories.jobs import ExperimentRepository, JobRepository
|
||||
@@ -37,6 +38,9 @@ from app.infrastructure.persistence.sqlalchemy.repositories.combo_impl import (
|
||||
from app.infrastructure.persistence.sqlalchemy.repositories.composite_impl import (
|
||||
SqlAlchemyCompositeRepository,
|
||||
)
|
||||
from app.infrastructure.persistence.sqlalchemy.repositories.condition_field_impl import (
|
||||
SqlAlchemyConditionFieldRepository,
|
||||
)
|
||||
from app.infrastructure.persistence.sqlalchemy.repositories.factor_impl import (
|
||||
SqlAlchemyFactorRepository,
|
||||
)
|
||||
@@ -174,6 +178,10 @@ def _factor_repo_factory(session: DbSession) -> FactorRepository:
|
||||
return SqlAlchemyFactorRepository(session)
|
||||
|
||||
|
||||
def _condition_field_repo_factory(session: DbSession) -> ConditionFieldRepository:
|
||||
return SqlAlchemyConditionFieldRepository(session)
|
||||
|
||||
|
||||
def _composite_repo_factory(session: DbSession) -> CompositeRepository:
|
||||
return SqlAlchemyCompositeRepository(session)
|
||||
|
||||
@@ -201,6 +209,7 @@ ResearchServiceDep = Annotated[ResearchService, Depends(_service_factory)]
|
||||
SelectionServiceDep = Annotated[SelectionService, Depends(_selection_service_factory)]
|
||||
SelectionRepoDep = Annotated[SelectionRepository, Depends(_selection_repo_factory)]
|
||||
FactorRepoDep = Annotated[FactorRepository, Depends(_factor_repo_factory)]
|
||||
ConditionFieldRepoDep = Annotated[ConditionFieldRepository, Depends(_condition_field_repo_factory)]
|
||||
CompositeRepoDep = Annotated[CompositeRepository, Depends(_composite_repo_factory)]
|
||||
SignalRepoDep = Annotated[SignalRepository, Depends(_signal_repo_factory)]
|
||||
SignalServiceDep = Annotated[SignalService, Depends(_signal_service_factory)]
|
||||
|
||||
+144
-21
@@ -1,34 +1,157 @@
|
||||
"""因子目录 API:/api/factors(M7.1 起读 DB factor_definition)。
|
||||
"""因子目录 API:/api/factors(M7.1 起读 DB,2026-10 支持参数化实例)。
|
||||
|
||||
目录为空时自动从代码注册表 seed(幂等);随后可登记自定义因子元数据。
|
||||
响应为 FactorDefinition 实体(含 requires 列表等)。
|
||||
## 目录的三条规则(详见 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 fastapi import APIRouter
|
||||
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 seed_registry_factors
|
||||
from app.domain.entities.factor import FactorDefinition
|
||||
from app.quant.factors import list_factors
|
||||
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"])
|
||||
|
||||
|
||||
@router.get("", summary="因子目录(含元数据,来自 factor_definition 表)")
|
||||
def list_factor_catalog(factor_repo: FactorRepoDep, session: DbSession) -> list[FactorDefinition]:
|
||||
"""读目录;**缺失的注册表因子当场补齐**(幂等),稳态下零写入。
|
||||
class FactorTemplateOut(BaseModel):
|
||||
"""模板(算法家族):可编辑参数 + 默认值 + 口径说明,供「新建参数化因子」表单。"""
|
||||
|
||||
历史 bug:原先只在「表为空」时 seed,于是表非空后**代码里新增的因子永远进不了目录**——
|
||||
实测表内 9 条、注册表 11 条,`dividend_yield` / `dividend_yield_ttm` 长期缺失,
|
||||
前端因子下拉选不到「股息率」、归档页也查不到它的方向与含义(违反 §7 不静默)。
|
||||
现在按「注册表有、库里没有」的差集触发 upsert:既保证目录与可计算因子一致,
|
||||
又不会覆盖用户登记的自定义因子元数据(只补不删)。
|
||||
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`,界面显示为不可用。
|
||||
"""
|
||||
existing = factor_repo.list()
|
||||
missing = {d.name for d in list_factors()} - {f.name for f in existing}
|
||||
if not existing or missing:
|
||||
seed_registry_factors(factor_repo, session)
|
||||
existing = factor_repo.list()
|
||||
return existing
|
||||
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)
|
||||
|
||||
@@ -42,7 +42,9 @@ def _decode_result(kind: str, result_json: str | None):
|
||||
return None
|
||||
if kind == "selection":
|
||||
return SelectionResult.model_validate_json(result_json)
|
||||
model = BacktestResult if kind == "backtest" else FactorTestReport
|
||||
# combo 回测的归档 kind 记为 "backtest",但 Job.kind 仍是 "combo" ——
|
||||
# 其结果同样是 BacktestResult,按 backtest 解码(否则会被当成因子测试而校验失败)。
|
||||
model = BacktestResult if kind in ("backtest", "combo") else FactorTestReport
|
||||
return model.model_validate_json(result_json)
|
||||
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ from app.api import (
|
||||
charts,
|
||||
combos,
|
||||
composites,
|
||||
condition_fields,
|
||||
config,
|
||||
experiments,
|
||||
factors,
|
||||
@@ -30,6 +31,7 @@ api_router = APIRouter()
|
||||
api_router.include_router(health.router)
|
||||
api_router.include_router(stocks.router)
|
||||
api_router.include_router(factors.router)
|
||||
api_router.include_router(condition_fields.router)
|
||||
api_router.include_router(composites.router)
|
||||
api_router.include_router(research.router)
|
||||
api_router.include_router(selections.router)
|
||||
|
||||
@@ -4,13 +4,20 @@
|
||||
回测改由「回测组合」(/api/combos)驱动,故旧的 /{id}/expand(→ResearchSpec)已移除。
|
||||
|
||||
POST /api/strategies 保存策略(name 唯一;description 为空时自动补全)
|
||||
POST /api/strategies/describe body: ResearchSpec → StrategyDoc(未保存的策略也能预览)
|
||||
POST /api/strategies/describe body: ResearchSpec → StrategyDoc(见下方说明,非策略库路径)
|
||||
GET /api/strategies 列表
|
||||
GET /api/strategies/{id}
|
||||
PUT /api/strategies/{id} 原地更新(不新建、不刷新 created_at)
|
||||
DELETE /api/strategies/{id}
|
||||
GET /api/strategies/{id}/describe → StrategyDoc
|
||||
|
||||
关于两个 describe 端点(不是历史遗留,各有明确用途,别合并):
|
||||
- `GET /{id}/describe` → 入参是已保存的 **SelectionStrategy**(策略库「看说明/公式」用);
|
||||
- `POST /describe` → 入参是 **ResearchSpec**,**给归档页**用:`/experiments/{id}` 要按当时
|
||||
归档的旧 ResearchSpec 快照(单策略回测路径,含 selection/rebalance/costs)复述口径。
|
||||
该路径仍然存在(`POST /api/backtests` 是底层 escape hatch),所以这里必须继续支持。
|
||||
回测页本身已不再调用它(组合回测走 ComboRunSpec + 归档页组合卡片)。
|
||||
|
||||
路由顺序注意:`/describe` 这类**字面量路径**一律声明在 `/{strategy_id}` 之前 ——
|
||||
否则会被路径参数吞掉(AGENT.md §17 的既有教训,/api/stocks/names 同源问题)。
|
||||
"""
|
||||
@@ -71,10 +78,11 @@ def create_strategy(
|
||||
|
||||
@router.post("/describe", response_model=StrategyDoc, summary="按 ResearchSpec 生成策略说明与公式")
|
||||
def describe_research_spec(spec: ResearchSpec) -> StrategyDoc:
|
||||
"""回测页参数即时预览用:**未保存的策略**(只有 spec)也能生成说明/公式。
|
||||
"""按 **ResearchSpec** 生成说明/公式 —— 服务于归档页,不是策略库路径。
|
||||
|
||||
纯函数实现(app.quant.strategy_doc),无 IO/DB,因此不会因保存状态而失败。
|
||||
路径与 `POST /api/strategies` 不冲突(字面量 /describe 优先于路径参数声明)。
|
||||
调用方是 `/experiments/{id}`:它按归档里冻结的 ResearchSpec 快照(单策略回测)复述
|
||||
「选股条件 + 交易执行依据」。纯函数实现(app.quant.strategy_doc),无 IO/DB,因此
|
||||
不依赖任何保存状态,历史归档随时可复述。策略库自身的说明走 `GET /{id}/describe`。
|
||||
"""
|
||||
return describe_strategy(spec)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user