Files
qlib/backend/app/api/strategies.py
T
Simon 40bd603b44 feat(backend): 策略库重构为「选股策略 + 公共配置 + 回测组合」三件套
按用户目标把原来「一个策略 = 全套参数」拆开(已确认的设计决策):
- 公共配置 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)。
2026-09-30 21:43:28 +08:00

149 lines
6.7 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""选股策略 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}