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
+66 -7
View File
@@ -1,25 +1,41 @@
"""Experiment API(Phase 4):列表 / 详情 / 一键复跑。"""
"""Experiment API(Phase 4):列表 / 详情 / 删除 / 一键复跑。"""
from __future__ import annotations
import json
from datetime import datetime
from typing import Annotated
from fastapi import APIRouter, BackgroundTasks, HTTPException
from fastapi import APIRouter, BackgroundTasks, HTTPException, Query, Response
from app.api.deps import DbSession, ExperimentRepoDep, JobRepoDep
from app.application.services.job_executor import new_id, run_job_background
from app.domain.entities.research import (
ExperimentRecord,
ExperimentSummary,
JobRecord,
JobStatus,
)
router = APIRouter(prefix="/experiments", tags=["experiments"])
# 列表默认返回条数与上限:原实现硬编码 limit=50 且不暴露总数,>50 条时更老的
# 归档静默不可见(AGENT.md §7);现在默认 200、上限 1000,并用 X-Total-Count
# 暴露过滤后的真实总数,客户端可据此翻页(offset)。
LIST_DEFAULT_LIMIT = 200
LIST_MAX_LIMIT = 1000
def _experiment_meta(exp: ExperimentRecord) -> dict:
def _experiment_meta(exp: ExperimentRecord | ExperimentSummary) -> dict:
"""列表项视图(body 形状与旧版一致,仅**新增** data_version / job_id / result_bytes)。
`exp` 可以是完整实体(详情路径)或 `ExperimentSummary`(列表路径,不含
result_json):两条路径都只读元数据字段,无需把大字段拉回来算体积。
"""
spec = json.loads(exp.spec_json)
result_bytes = getattr(exp, "result_bytes", None)
if result_bytes is None: # 完整实体:result_json 已在内存,len() 零成本
result_bytes = len(exp.result_json or "")
return {
"id": exp.id,
"kind": exp.kind,
@@ -29,6 +45,9 @@ def _experiment_meta(exp: ExperimentRecord) -> dict:
"top_n": spec.get("selection", {}).get("top_n"),
"summary_text": exp.summary_text,
"code_version": exp.code_version,
"data_version": exp.data_version,
"job_id": exp.job_id,
"result_bytes": int(result_bytes),
"created_at": exp.created_at,
}
@@ -43,9 +62,27 @@ def _experiment_full(exp: ExperimentRecord) -> dict:
}
@router.get("", summary="Experiment 列表")
def list_experiments(experiment_repo: ExperimentRepoDep) -> list[dict]:
return [_experiment_meta(e) for e in experiment_repo.list_recent(limit=50)]
@router.get("", summary="Experiment 列表(过滤 + 分页,X-Total-Count 给总数)")
def list_experiments(
experiment_repo: ExperimentRepoDep,
response: Response,
kind: Annotated[str | None, Query(description="按类型精确过滤(backtest/factor_test/selection)")] = None,
q: Annotated[
str | None, Query(description="大小写不敏感模糊匹配 id / 因子名 / summary_text")
] = None,
limit: Annotated[int, Query(ge=1, le=LIST_MAX_LIMIT)] = LIST_DEFAULT_LIMIT,
offset: Annotated[int, Query(ge=0)] = 0,
) -> list[dict]:
"""归档列表。
- 过滤与分页在 SQL 层完成(仓储 `list_filtered`),不把全表拉回内存;
- 响应头 `X-Total-Count` = **过滤后**的归档总数(不受 limit/offset 影响),
客户端据此判断是否被截断并翻页(AGENT.md §7:不静默截断)。
"""
rows = experiment_repo.list_filtered(kind=kind, q=q, limit=limit, offset=offset)
total = experiment_repo.count_filtered(kind=kind, q=q)
response.headers["X-Total-Count"] = str(total)
return [_experiment_meta(e) for e in rows]
@router.get("/{experiment_id}", summary="Experiment 详情(含完整结果)")
@@ -56,6 +93,28 @@ def get_experiment(experiment_id: str, experiment_repo: ExperimentRepoDep) -> di
return _experiment_full(exp)
@router.delete("/{experiment_id}", summary="删除 Experiment 归档(不影响关联 Job 记录)")
def delete_experiment(
experiment_id: str,
session: DbSession,
experiment_repo: ExperimentRepoDep,
) -> dict:
"""删除归档本身,返回 `{"deleted": "<id>"}`。
语义(写清楚避免误解):
- **只删 experiment 行**。关联的 job 记录是「执行历史」,一律保留,
`GET /api/jobs/{id}` 仍可查到该 Job 的状态、阶段与错误信息。
- 注意:完整结果现在只存归档一份(见 experiment_archive / job_executor),
因此删除归档后 `GET /api/jobs/{id}` 的 `result` 会是 null,并附带
`result_unavailable_reason` 说明归档已被删除(如实暴露,不静默给空结果)。
- 删除不可恢复;如需长期保留结果,请勿删除对应归档。
"""
if not experiment_repo.delete(experiment_id):
raise HTTPException(status_code=404, detail=f"Experiment {experiment_id} 不存在")
session.commit()
return {"deleted": experiment_id}
@router.post("/{experiment_id}/rerun", summary="一键复跑(AGENT §21:历史实验可重放)")
def rerun_experiment(
experiment_id: str,
@@ -77,4 +136,4 @@ def rerun_experiment(
job_repo.create(job)
session.commit()
background.add_task(run_job_background, job.id)
return {"job_id": job.id, "status": job.status, "origin_experiment": exp.id}
return {"job_id": job.id, "status": job.status, "origin_experiment": exp.id}