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
+125
View File
@@ -0,0 +1,125 @@
"""实验归档保留策略 CLI(归档体积治理)。
用法(cd backend):
# 只看会删什么(**默认就是 dry-run**,不写库)
uv run python -m app.cli.prune_experiments --keep 30
uv run python -m app.cli.prune_experiments --keep 20 --kind backtest
uv run python -m app.cli.prune_experiments --older-than 180 # 180 天前的归档
# 真正删除(必须显式加 --apply)
uv run python -m app.cli.prune_experiments --keep 30 --apply
为什么默认 dry-run:归档是**研究结论的唯一凭据**(结果 + spec + 代码/数据版本),
误删不可恢复。因此:
- 默认只打印候选清单(含 id / 类型 / 时间 / 体积 / 摘要)与合计释放空间,不写库;
- 只有显式 `--apply` 才执行删除,且删除前**再打印一次**清单;
- 按 `created_at` **由新到旧保留**:先按过滤条件选出候选集合,再保留最新的 N 条,
其余删除(`--keep` 与 `--older-than` 可同时给,取交集);
- 只删 `experiment` 表(归档本身),**不动 `job_record`**(执行历史仍可追溯);
但要注意:完整结果只存归档一份(`job.result_json` 对新记录为 NULL),
因此**删除归档 = 该次回测结果不可再查看**。要留底先导出(归档页「导出完整 JSON」);
- `--kind` 可只清理某一类(如只清 `factor_test`,保留回测结论)。
本模块是组装层(composition root):装配 Session 与 Repository,删除逻辑走仓储。
"""
from __future__ import annotations
import argparse
import sys
from datetime import datetime, timedelta
from app.infrastructure.persistence.sqlalchemy.repositories.jobs_impl import (
SqlAlchemyExperimentRepository,
)
from app.infrastructure.persistence.sqlalchemy.session import SessionLocal
def select_prune_candidates(
rows: list,
*,
keep: int | None,
older_than_days: int | None,
now: datetime | None = None,
) -> list:
"""从归档列表里挑出**该删除**的那些(纯函数,便于单测)。
`rows`:任意具备 `id` / `created_at` 的归档摘要(不依赖 ORM 与数据库)。
规则(两个条件同时给出时取交集):
- `keep=N`:按 `created_at` 由新到旧排序后,**保留最新 N 条**,其余为候选;
- `older_than_days=D`:创建时间早于 `now - D 天` 的才是候选;
- `created_at` 为空的归档视为**最旧**(排序末位)——缺失时间时宁可被列为候选,
也不静默把它当作「最新」而永久留在库里。
"""
ordered = sorted(rows, key=lambda e: (e.created_at or datetime.min), reverse=True)
candidates = list(ordered)
if older_than_days is not None:
cutoff = (now or datetime.now()) - timedelta(days=older_than_days)
candidates = [e for e in candidates if (e.created_at or datetime.min) < cutoff]
if keep is not None:
keep_ids = {e.id for e in ordered[: max(keep, 0)]}
candidates = [e for e in candidates if e.id not in keep_ids]
# 输出仍按新 → 旧,便于人核对
candidates.sort(key=lambda e: (e.created_at or datetime.min), reverse=True)
return candidates
def _parse_args(argv: list[str]) -> argparse.Namespace:
p = argparse.ArgumentParser(
prog="python -m app.cli.prune_experiments",
description="实验归档保留策略(默认 dry-run,需 --apply 才真正删除)",
)
p.add_argument("--keep", type=int, default=None, help="按时间由新到旧保留的最新条数")
p.add_argument("--kind", default=None, help="只处理某类归档(backtest/factor_test/selection)")
p.add_argument(
"--older-than",
type=int,
default=None,
metavar="DAYS",
help="只处理创建时间早于 N 天的归档",
)
p.add_argument("--apply", action="store_true", help="真正执行删除(否则仅预览)")
args = p.parse_args(argv)
if args.keep is None and args.older_than is None:
p.error("至少给出 --keep 或 --older-than 之一,避免误删全部归档")
if args.keep is not None and args.keep < 0:
p.error("--keep 不能为负数")
return args
def main(argv: list[str] | None = None) -> int:
args = _parse_args(sys.argv[1:] if argv is None else argv)
with SessionLocal() as session:
repo = SqlAlchemyExperimentRepository(session)
# 列表查询刻意不取 result_json,体积用库侧算出的 result_bytes(单位:字符)
rows = repo.list_filtered(kind=args.kind, limit=1_000_000, offset=0)
candidates = select_prune_candidates(
rows, keep=args.keep, older_than_days=args.older_than
)
total_bytes = sum(e.result_bytes or 0 for e in candidates)
print(f"归档总数 {len(rows)} 条,命中删除条件 {len(candidates)} 条,"
f"预计释放约 {total_bytes / 1024 / 1024:.1f} MB")
for e in candidates:
created = e.created_at.strftime("%Y-%m-%d %H:%M") if e.created_at else "未知时间"
print(
f" - {e.id} {e.kind:<12} {created} "
f"{((e.result_bytes or 0) / 1024):>8.0f} KB {(e.summary_text or '')[:60]}"
)
if not candidates:
print("没有需要删除的归档。")
return 0
if not args.apply:
print("\n[dry-run] 未删除任何记录;确认无误后加 --apply 执行。")
return 0
deleted = 0
for e in candidates:
if repo.delete(e.id):
deleted += 1
session.commit()
print(f"\n已删除 {deleted} 条归档(job_record 未改动)。")
return 0
if __name__ == "__main__":
raise SystemExit(main())