汇总三轮未提交的开发(每轮均在本机 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 逐类验证归档页)。
172 lines
7.4 KiB
Python
172 lines
7.4 KiB
Python
"""从 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()) |