按用户目标把原来「一个策略 = 全套参数」拆开(已确认的设计决策):
- 公共配置 GlobalConfig(全局唯一):佣金/印花税/滑点/最低佣金/复权口径/基准
- 选股策略 SelectionStrategy(原 StrategyDefinition 改名):只剩股票池+因子+条件,
不再持有 selection/rebalance/costs/portfolio/区间/资金
- 回测组合 BacktestCombo:引用若干选股策略 + 回测时才定的参数
(起始资金、持仓数 N、持仓天数区间 [Tmin,Tmax]、调仓时机 日/周/月、区间)
引擎(app/quant/combo_engine.py,新增):
- 多策略打分 = 并集 + Borda 秩和(各策略 1/名次 求和;不假设不同策略分值可比,
能容纳各策略股票池不同);抽出纯函数 borda_combine 便于单测
- 持仓天数区间 [Tmin,Tmax]:Tmax **每个交易日**强制了结(安全阀,月频下也不超期);
Tmin 仅在调仓日保护(掉出 TopN 但未满 Tmin 暂留,防频繁换手);调仓日为增量调仓
(只卖超期/掉队且满 Tmin 的,从 TopN 补买至 N 只,不主动减持以尊重 Tmin)
- 调仓时机 daily/weekly/monthly(local_engine.rebalance_dates 新增日频分支)
- 产出与旧 runner 同构的 BacktestResult,前端可视化无需改动;config_snapshot 固化
ComboRunSpec(组合+当时各策略定义+当时成本/复权)保证可复现
数据层:
- 新表 global_config(默认行:万三/hfq/最低佣金5元)、backtest_combo
- 迁移 b4c5d6e7f8a9:建两表 + 把存量 strategy.config_json 的回测参数键剥掉、
spec_type 收敛为 selection(已在真实 MariaDB 验证:STG-16BFBF08 清洗后只剩
universe/factors/conditions)
- 仓储 SqlAlchemyGlobalConfigRepository / SqlAlchemyComboRepository + Protocol
API:
- /api/config GET/PUT;/api/combos CRUD + /{id}/run + /run(kind=combo 异步 Job)
- job_executor 新增 combo 分支:取齐策略+读公共配置→ComboService.run,归档 kind
记 backtest(结果结构相同)
- /api/strategies 切到 SelectionStrategy,移除已废弃的 /{id}/expand
- strategy_doc.describe_strategy 支持 SelectionStrategy(只讲「怎么选」,如实声明
资金/持仓/调仓/成本/区间在回测组合里定)
旧的 ResearchSpec + /api/backtests 保留(因子测试与既有契约自检仍用),
作为底层 escape hatch;用户产品路径改为回测组合。
测试:新增 test_combo_engine(6)/test_combo_service(3)/test_combo_api(5),
改写 test_strategies/test_strategy_doc 适配新模型。全量 403 passed(原 388)。
149 lines
6.7 KiB
Python
149 lines
6.7 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` 这类**字面量路径**一律声明在 `/{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}
|
||
|