Files
qlib/backend/tests/test_prune_experiments.py
T
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

70 lines
2.9 KiB
Python
Raw 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.
"""归档保留策略(`app.cli.prune_experiments`)的候选选择单测。
删除归档是**不可恢复**操作,因此候选集合的判定必须被测试钉住:多删一条就是丢了
一份研究结论,少删一条则保留策略失效。这里只测纯函数与参数护栏(不连数据库)。
"""
from __future__ import annotations
from datetime import datetime, timedelta
import pytest
from app.cli.prune_experiments import _parse_args, select_prune_candidates
class _Row:
"""最小归档摘要替身(只有 id / created_at 参与判定)。"""
def __init__(self, id_: str, age_days: float | None) -> None:
self.id = id_
self.created_at = None if age_days is None else NOW - timedelta(days=age_days)
NOW = datetime(2026, 9, 20, 12, 0, 0)
def _rows() -> list[_Row]:
return [_Row("E1", 1), _Row("E2", 2), _Row("E3", 10), _Row("E4", 30), _Row("E5", None)]
class TestSelectPruneCandidates:
def test_keep_newest_n(self) -> None:
got = [e.id for e in select_prune_candidates(_rows(), keep=2, older_than_days=None, now=NOW)]
assert got == ["E3", "E4", "E5"], "只保留最新 2 条(E1/E2),其余都是候选"
def test_keep_zero_deletes_all(self) -> None:
got = [e.id for e in select_prune_candidates(_rows(), keep=0, older_than_days=None, now=NOW)]
assert set(got) == {"E1", "E2", "E3", "E4", "E5"}
def test_older_than_only(self) -> None:
got = [e.id for e in select_prune_candidates(_rows(), keep=None, older_than_days=5, now=NOW)]
assert got == ["E3", "E4", "E5"], "5 天内创建的 E1/E2 必须保留"
def test_intersection_of_both_conditions(self) -> None:
got = [e.id for e in select_prune_candidates(_rows(), keep=4, older_than_days=5, now=NOW)]
# older_than 选出 E3/E4/E5,keep=4 保留 E1..E4 → 交集只剩 E5
assert got == ["E5"]
def test_missing_created_at_treated_as_oldest(self) -> None:
"""created_at 为空按最旧处理:不能被当作「最新」从而永久留库。"""
got = [e.id for e in select_prune_candidates(_rows(), keep=4, older_than_days=None, now=NOW)]
assert "E5" in got
def test_result_is_newest_first(self) -> None:
got = [e.id for e in select_prune_candidates(_rows(), keep=0, older_than_days=None, now=NOW)]
assert got == ["E1", "E2", "E3", "E4", "E5"], "输出按新→旧,便于人工核对"
class TestArgGuard:
def test_requires_keep_or_older_than(self) -> None:
with pytest.raises(SystemExit):
_parse_args([]) # 两个条件都不给 → 拒绝执行(避免误删全部)
def test_negative_keep_rejected(self) -> None:
with pytest.raises(SystemExit):
_parse_args(["--keep", "-1"])
def test_default_is_dry_run(self) -> None:
args = _parse_args(["--keep", "10"])
assert args.apply is False, "默认必须 dry-run,只有显式 --apply 才写库"