Files
qlib/backend/app/api/strategies.py
T
Simon 23972e7063 feat: 股息率案例口径 + 策略库与图表统一 + 回测存档完整化
汇总三轮未提交的开发(每轮均在本机 MariaDB + 真实浏览器上验证):

1) 股息率案例(全市场股息率最高 n 只,默认 20,每 m 月择股)
   - 新增日频估值表 daily_basic + 迁移;股息率因子(dv_ratio / dividend_yield / TTM)
   - 名称历史表 stock_name_history:剔除 ST 按**择股日当时名称**判定,消除
     「曾高股息后 ST」的股息陷阱(实测 3.70pp 偏差)
   - 区间择股/调仓双周期(m 择股 / y 调仓)、指数成分与白名单、停牌近似剔除
   - 复权因子口径核对(4,164,742 行、缺失 0.0%)、收盘价成交与涨跌停拦单
   - 案例实测:2020-01-01~2026-09-04 总收益 +24.86%(年化 3.52%、回撤 -28.58%)

2) 策略库与前端统一
   - strategy 表 + CRUD/PUT 原地更新 + `describe_strategy` 按 spec 真实推导
     「一句话说明 + 计算公式 + 执行步骤 + 注意事项」(与引擎实执行规则同源)
   - 任何出现股票代码处都成对显示名称且可点击进个股页
   - 全站图表基座统一 TradingView Lightweight Charts(ECharts 依赖、
     锁文件、组件与文档标注一并清除),买卖点标记只落在真实交易日上

3) 回测存档完整化(可往复查看)
   - 同步端点(POST /api/backtests、/api/factor-tests)此前完全不落库 → 现在同样归档,
     归档 id 经响应头 X-Experiment-Id 返回(不破坏 response_model)
   - data_version 首次真实写入(数据快照指纹:最新交易日 + 各表规模)
   - 个股收益曲线默认**全量保存**(此前硬截断 60 只);超出体积预算才裁剪,
     并写 archive_meta(机器可读)+ unimplemented(人可读)如实标注
   - 列表 kind/q 过滤 + X-Total-Count(此前 limit=50 静默截断)、DELETE 归档
   - 只读归档页 /experiments/{id}(Server Component,SSR 直出**选股条件**与
     **交易执行依据**);结果视图按 kind 分发(backtest/factor_test/selection),
     非回测归档不套用回测口径
   - 新增 CLI:prune_experiments(保留策略,默认 dry-run)、
     restore_experiment_from_job(从 Job 副本按原 id 重建被删的历史归档,默认 dry-run)

门禁:pytest 388 passed、ruff All checks passed、tsc 0 错误、图表单测 7 passed、
next build 成功、契约脚本 verify_strategy_workspace 59/59(含按 kind 逐类验证归档页)。
2026-09-20 07:31:04 +08:00

168 lines
7.5 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(M8.3):/api/strategies CRUD + 展开为 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}
POST /api/strategies/{id}/expand body: {period:[start,end], initial_capital?} → ResearchSpec
GET /api/strategies/{id}/describe → StrategyDoc
路由顺序注意:`/describe` 这类**字面量路径**一律声明在 `/{strategy_id}` 之前 ——
否则会被路径参数吞掉(AGENT.md §17 的既有教训,/api/stocks/names 同源问题)。
"""
from __future__ import annotations
from datetime import date
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel, Field
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 StrategyDefinition
from app.quant.strategy_doc import StrategyDoc, describe_strategy
router = APIRouter(prefix="/strategies", tags=["strategies"])
class ExpandRequest(BaseModel):
period: tuple[date, date]
initial_capital: float = Field(default=1_000_000.0, gt=0)
# strategy.description 列宽(StrategyModel.description = String(300))。
# 自动补全的说明必须落在列宽内,否则 MySQL 严格模式会直接报 Data too long(SQLite 不拦,
# 所以只在测试库上跑是发现不了的)。超长时按字符截断并加省略号 —— 显式标记有截断,
# 不做「悄悄改短」;完整说明始终可由 POST /describe 重新生成。
_DESCRIPTION_MAX_CHARS = 300
def _ensure_description(definition: StrategyDefinition) -> StrategyDefinition:
"""说明为空/纯空白时,用 `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=StrategyDefinition, summary="保存策略")
def create_strategy(
definition: StrategyDefinition,
strategy_repo: StrategyRepoDep,
session: DbSession,
) -> StrategyDefinition:
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[StrategyDefinition], summary="策略列表")
def list_strategies(strategy_repo: StrategyRepoDep) -> list[StrategyDefinition]:
return strategy_repo.list()
@router.get("/{strategy_id}", response_model=StrategyDefinition, summary="读取策略")
def get_strategy(strategy_id: str, strategy_repo: StrategyRepoDep) -> StrategyDefinition:
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` 中已如实标注),
展开回测后(/expand)再用该 ResearchSpec 调 `POST /describe` 即为确定区间的版本。
"""
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=StrategyDefinition, summary="原地更新策略")
def update_strategy(
strategy_id: str,
definition: StrategyDefinition,
strategy_repo: StrategyRepoDep,
session: DbSession,
) -> StrategyDefinition:
"""原地更新(策略库「编辑」用):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}
@router.post("/{strategy_id}/expand", response_model=ResearchSpec, summary="展开为研究 Spec")
def expand_strategy(
strategy_id: str,
req: ExpandRequest,
strategy_repo: StrategyRepoDep,
) -> ResearchSpec:
row = strategy_repo.get(strategy_id)
if row is None:
raise HTTPException(status_code=404, detail=f"策略 {strategy_id} 不存在")
if req.period[0] >= req.period[1]:
raise HTTPException(status_code=400, detail="period 必须满足 start < end")
return row.to_research_spec(period=req.period, initial_capital=req.initial_capital)