"""选股策略 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` 这类**字面量路径**一律声明在 `/{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: """回测页参数即时预览用:**未保存的策略**(只有 spec)也能生成说明/公式。 纯函数实现(app.quant.strategy_doc),无 IO/DB,因此不会因保存状态而失败。 路径与 `POST /api/strategies` 不冲突(字面量 /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}