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 逐类验证归档页)。
This commit is contained in:
Simon
2026-09-20 07:31:04 +08:00
parent 7e15b7251e
commit 23972e7063
112 changed files with 17908 additions and 3893 deletions
+93 -6
View File
@@ -1,10 +1,16 @@
"""策略 API(M8.3):/api/strategies CRUD + 展开为 ResearchSpec。
"""策略 API(M8.3):/api/strategies CRUD + 展开为 ResearchSpec + 说明/公式生成。
POST /api/strategies 保存策略(name 唯一)
GET /api/strategies 列表
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
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
@@ -18,6 +24,7 @@ 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"])
@@ -27,6 +34,28 @@ class ExpandRequest(BaseModel):
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,
@@ -34,11 +63,25 @@ def create_strategy(
session: DbSession,
) -> StrategyDefinition:
try:
saved = strategy_repo.save(definition.model_copy(update={"id": new_id("STG")}))
saved = strategy_repo.save(
_ensure_description(definition).model_copy(update={"id": new_id("STG")})
)
session.commit()
return saved
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="策略列表")
@@ -54,6 +97,50 @@ def get_strategy(strategy_id: str, strategy_repo: StrategyRepoDep) -> StrategyDe
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,