字段库(本次新增的表与接口): - `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。
157 lines
7.5 KiB
Python
157 lines
7.5 KiB
Python
"""选股策略 API:/api/strategies CRUD + 说明/公式生成。
|
||
|
||
2026-09 重构:策略库只存「选股条件组合」(股票池+因子+条件),不再持有回测执行参数;
|
||
回测改由「回测组合」(/api/combos)驱动,故旧的 /{id}/expand(→ResearchSpec)已移除。
|
||
|
||
POST /api/strategies 保存策略(name 唯一;description 为空时自动补全)
|
||
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 同源问题)。
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
from fastapi import APIRouter, HTTPException
|
||
|
||
from app.api.deps import DbSession, StrategyRepoDep
|
||
from app.application.services.job_executor import new_id
|
||
from app.domain.entities.research import ResearchSpec
|
||
from app.domain.entities.strategy import SelectionStrategy
|
||
from app.quant.strategy_doc import StrategyDoc, describe_strategy
|
||
|
||
router = APIRouter(prefix="/strategies", tags=["strategies"])
|
||
|
||
|
||
|
||
# strategy.description 列宽(StrategyModel.description = String(300))。
|
||
# 自动补全的说明必须落在列宽内,否则 MySQL 严格模式会直接报 Data too long(SQLite 不拦,
|
||
# 所以只在测试库上跑是发现不了的)。超长时按字符截断并加省略号 —— 显式标记有截断,
|
||
# 不做「悄悄改短」;完整说明始终可由 POST /describe 重新生成。
|
||
_DESCRIPTION_MAX_CHARS = 300
|
||
|
||
|
||
def _ensure_description(definition: SelectionStrategy) -> SelectionStrategy:
|
||
"""说明为空/纯空白时,用 `describe_strategy(...).summary` 补全(需求:策略必须有说明)。
|
||
|
||
说明由 spec **真实推导**(AGENT.md §24:不许编造),只在空值时补、不覆盖显式说明。
|
||
放在 API 层是因为这是「保存契约」的准入补全;Agent 的 create_strategy 工具走仓储
|
||
直写(description 非必填),因此不受影响(AGENT.md §28 工具链路保持可用)。
|
||
"""
|
||
if definition.description.strip():
|
||
return definition
|
||
summary = describe_strategy(definition).summary
|
||
if len(summary) > _DESCRIPTION_MAX_CHARS:
|
||
summary = summary[: _DESCRIPTION_MAX_CHARS - 1] + "…"
|
||
return definition.model_copy(update={"description": summary})
|
||
|
||
|
||
@router.post("", response_model=SelectionStrategy, summary="保存选股策略")
|
||
def create_strategy(
|
||
definition: SelectionStrategy,
|
||
strategy_repo: StrategyRepoDep,
|
||
session: DbSession,
|
||
) -> SelectionStrategy:
|
||
try:
|
||
saved = strategy_repo.save(
|
||
_ensure_description(definition).model_copy(update={"id": new_id("STG")})
|
||
)
|
||
session.commit()
|
||
except ValueError as exc:
|
||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||
# 回读持久化后的实体:仓储 save() 返回的是入参(created_at 为空),
|
||
# 直接返回会让 POST 响应缺创建时间、与 GET/列表不一致(前端展示依赖该字段)。
|
||
return strategy_repo.get(saved.id) or saved
|
||
|
||
|
||
@router.post("/describe", response_model=StrategyDoc, summary="按 ResearchSpec 生成策略说明与公式")
|
||
def describe_research_spec(spec: ResearchSpec) -> StrategyDoc:
|
||
"""按 **ResearchSpec** 生成说明/公式 —— 服务于归档页,不是策略库路径。
|
||
|
||
调用方是 `/experiments/{id}`:它按归档里冻结的 ResearchSpec 快照(单策略回测)复述
|
||
「选股条件 + 交易执行依据」。纯函数实现(app.quant.strategy_doc),无 IO/DB,因此
|
||
不依赖任何保存状态,历史归档随时可复述。策略库自身的说明走 `GET /{id}/describe`。
|
||
"""
|
||
return describe_strategy(spec)
|
||
|
||
|
||
@router.get("", response_model=list[SelectionStrategy], summary="选股策略列表")
|
||
def list_strategies(strategy_repo: StrategyRepoDep) -> list[SelectionStrategy]:
|
||
return strategy_repo.list()
|
||
|
||
|
||
@router.get("/{strategy_id}", response_model=SelectionStrategy, summary="读取选股策略")
|
||
def get_strategy(strategy_id: str, strategy_repo: StrategyRepoDep) -> SelectionStrategy:
|
||
row = strategy_repo.get(strategy_id)
|
||
if row is None:
|
||
raise HTTPException(status_code=404, detail=f"策略 {strategy_id} 不存在")
|
||
return row
|
||
|
||
|
||
@router.get("/{strategy_id}/describe", response_model=StrategyDoc, summary="生成策略说明与公式")
|
||
def describe_saved_strategy(
|
||
strategy_id: str, strategy_repo: StrategyRepoDep
|
||
) -> StrategyDoc:
|
||
"""已保存策略的说明/公式(404 语义与 GET /{strategy_id} 一致)。
|
||
|
||
策略定义不含回测区间,说明里的区间为占位文本(`warnings` 中已如实标注),
|
||
"""
|
||
row = strategy_repo.get(strategy_id)
|
||
if row is None:
|
||
raise HTTPException(status_code=404, detail=f"策略 {strategy_id} 不存在")
|
||
return describe_strategy(row)
|
||
|
||
|
||
@router.put("/{strategy_id}", response_model=SelectionStrategy, summary="原地更新选股策略")
|
||
def update_strategy(
|
||
strategy_id: str,
|
||
definition: SelectionStrategy,
|
||
strategy_repo: StrategyRepoDep,
|
||
session: DbSession,
|
||
) -> SelectionStrategy:
|
||
"""原地更新(策略库「编辑」用):id 以**路径**为准,created_at 沿用库中已有值。
|
||
|
||
为什么必须显式带上 created_at:仓储 `save()` 只在 created_at 为空时才写 now()
|
||
(既有行不会覆盖该列),但返回值是**传入的实体**;若这里不带,响应里的创建时间
|
||
就会变成 None,而策略库按创建时间展示 —— 一改就丢时间同样会误导前端。
|
||
改名撞车由仓储 `save()` 抛 ValueError(策略名已存在:X),这里转 400。
|
||
"""
|
||
existing = strategy_repo.get(strategy_id)
|
||
if existing is None:
|
||
raise HTTPException(status_code=404, detail=f"策略 {strategy_id} 不存在")
|
||
payload = definition.model_copy(
|
||
update={"id": strategy_id, "created_at": existing.created_at}
|
||
)
|
||
try:
|
||
saved = strategy_repo.save(_ensure_description(payload))
|
||
session.commit()
|
||
except ValueError as exc:
|
||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||
# 与 create 一致:回读持久化实体,保证响应 == GET 读回(含 description/created_at)
|
||
return strategy_repo.get(saved.id) or saved
|
||
|
||
|
||
@router.delete("/{strategy_id}", summary="删除策略")
|
||
def delete_strategy(
|
||
strategy_id: str,
|
||
strategy_repo: StrategyRepoDep,
|
||
session: DbSession,
|
||
) -> dict:
|
||
if not strategy_repo.delete(strategy_id):
|
||
raise HTTPException(status_code=404, detail=f"策略 {strategy_id} 不存在")
|
||
session.commit()
|
||
return {"deleted": strategy_id}
|
||
|