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

172 lines
7.4 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.
"""从 Job 结果副本重建 Experiment 归档(删除后的可选恢复路径)。
**为什么会有这个工具**:完整存档上线前,历史归档在 `job.result_json` 里另存了一份完整结果
(双写遗留)。删除归档只删 `experiment` 行、不动 `job` 行,因此这些历史记录
**可以从 Job 副本原样重建** —— 删除不等于数据永久消失。
新归档(`job.result_json IS NULL`)没有副本,删除即不可恢复,本工具会明确拒绝而不是假装能救。
注意:本工具是**恢复手段**,不是"删除的撤销键"。删除本身是正常功能:
已有历史归档可重建、新归档不可;是否恢复由人决定,工具默认 dry-run、不做任何自动动作。
用法(**默认 dry-run**,只打印将要写入的内容;`--apply` 才写库):
cd backend
PYTHONPATH=. .venv/bin/python -m app.cli.restore_experiment_from_job --job-id JOB-XXXX
PYTHONPATH=. .venv/bin/python -m app.cli.restore_experiment_from_job --job-id JOB-XXXX \
--code-version 92627f5 --apply
诚实性要求(AGENT.md §7/§24):
- **归档 id 用回原 id**(`job.experiment_id`),移动端/书签里的旧链接继续有效;
- `spec_json` / `result_json` 逐字复制 Job 副本,不重新计算、不"顺手修正";
- `summary_text` 由副本 JSON 反序列化后按归档同一函数重新生成(口径一致);
- `code_version` **必须显式提供**,不做猜测:显式传 `--code-version ""` 表示"未知则留空";
- `data_version` 留空 —— 历史归档当年没有数据指纹,补一个今天的指纹是伪造复现依据;
- 重建前会检查该 job 的归档是否已存在,已存在则拒绝(除非 `--force`)。
"""
from __future__ import annotations
import argparse
import json
import sys
from datetime import datetime
from app.application.services.experiment_archive import _summary_text
from app.domain.entities.research import (
BacktestResult,
ExperimentRecord,
FactorTestReport,
)
from app.domain.entities.selection import SelectionResult
from app.infrastructure.persistence.sqlalchemy.repositories.jobs_impl import (
SqlAlchemyExperimentRepository,
)
from app.infrastructure.persistence.sqlalchemy.session import SessionLocal
def _parse_result(kind: str, raw: str):
"""按 kind 把 Job 里的结果 JSON 反序列化成领域对象(用于生成同一个摘要口径)。"""
payload = json.loads(raw)
if kind == "backtest":
return BacktestResult.model_validate(payload)
if kind == "factor_test":
return FactorTestReport.model_validate(payload)
if kind == "selection":
return SelectionResult.model_validate(payload)
return None
def _load_job(session, job_id: str):
"""按 ORM 读 Job(**不要用裸 SQL**)。
裸 `text()` 查询在 SQLite 下把 DateTime 列原样返回成字符串,写回 ORM 的 DateTime
字段会抛 `TypeError: SQLite DateTime type only accepts Python datetime...`
(MySQL+pymysql 返回 datetime 所以当时看不出问题)。ORM 读法跨方言类型一致,
这个坑由 `tests/test_restore_experiment.py::test_restores_archive_from_job_copy` 钉住。
"""
from app.infrastructure.persistence.sqlalchemy.models.jobs import JobModel
return session.get(JobModel, job_id)
def _experiment_exists(session, exp_id: str) -> bool:
from app.infrastructure.persistence.sqlalchemy.models import ExperimentModel
return session.get(ExperimentModel, exp_id) is not None
def main(argv: list[str] | None = None) -> int:
ap = argparse.ArgumentParser(description="从 Job 结果副本重建 Experiment 归档(删除后的可选恢复路径)")
ap.add_argument("--job-id", required=True, help="来源 Job id(如 JOB-D3C120DC)")
ap.add_argument(
"--code-version",
default=None,
help="重建记录的 code_version(必填;传空串表示未知留空)。不猜版本。",
)
ap.add_argument("--force", action="store_true", help="归档已存在时也覆盖(默认拒绝)")
ap.add_argument("--apply", action="store_true", help="真正写库(默认 dry-run)")
args = ap.parse_args(argv)
if args.code_version is None:
print(
"❌ 必须显式给出 --code-version(不猜版本);确定未知请传 --code-version \"\"。",
file=sys.stderr,
)
return 2
session = SessionLocal()
try:
row = _load_job(session, args.job_id)
if row is None:
print(f"❌ Job {args.job_id} 不存在", file=sys.stderr)
return 1
job_id = row.id
kind = row.kind
status = row.status
spec_json = row.spec_json
result_json = row.result_json
exp_id = row.experiment_id
finished_at = row.finished_at
created_at = row.created_at
if not exp_id:
print(f"❌ Job {job_id} 没有 experiment_id,无法确定重建为哪个归档", file=sys.stderr)
return 1
if result_json is None:
print(
f"❌ Job {job_id} 的 result_json 为空(完整存档上线后结果只存归档一份),"
"本工具无法重建 —— 这类归档删除后不可恢复。",
file=sys.stderr,
)
return 1
exists = _experiment_exists(session, exp_id)
result = _parse_result(kind, result_json)
summary = _summary_text(kind, result) if result is not None else None
restored_at = finished_at or created_at or datetime.now()
print(f"来源 Job : {job_id}(status={status},finished_at={finished_at})")
print(f"归档 id : {exp_id}(已存在: {exists})")
print(f"kind : {kind}")
print(f"spec 长度 : {len(spec_json)} 字符")
print(f"result 长度 : {len(result_json)} 字符")
print(f"summary_text : {summary!r}")
print(f"code_version : {args.code_version!r}")
print("data_version : None(历史归档无指纹,不伪造)")
print(f"created_at : {restored_at}")
if exists and not args.force:
print("❌ 该归档已存在;如确要覆盖请加 --force", file=sys.stderr)
return 1
if not args.apply:
print("\n(dry-run) 未写库。确认无误后加 --apply。")
return 0
# 走仓储而不是直接操作 ORM 模型(AGENT §10:持久化只经 Repository)。
# 这样 experiment 表 ↔ 领域实体的字段映射只有仓储一份:将来加列(尤其 NOT NULL)
# 不会在这里静默漏写。覆盖语义由 `upsert`(merge)承担,归档 id 保持不变。
record = ExperimentRecord(
id=exp_id,
kind=kind,
spec_json=spec_json,
result_json=result_json,
summary_text=summary,
code_version=args.code_version or None,
data_version=None,
job_id=job_id,
created_at=restored_at,
)
repo = SqlAlchemyExperimentRepository(session)
repo.upsert(record)
session.commit()
got = repo.get(exp_id)
print(
f"\n✅ 已重建:{got.id} / {got.kind} / 体积 {len(got.result_json)} 字符 / "
f"code_version={got.code_version}"
)
return 0
finally:
session.close()
if __name__ == "__main__":
raise SystemExit(main())