汇总三轮未提交的开发(每轮均在本机 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 逐类验证归档页)。
125 lines
5.7 KiB
Python
125 lines
5.7 KiB
Python
"""实验归档保留策略 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()) |