Files
qlib/backend/app/cli/prune_experiments.py
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

125 lines
5.7 KiB
Python
Raw Permalink 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.
"""实验归档保留策略 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())