Files
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

99 lines
4.0 KiB
TypeScript
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.
"use client";
/**
* 归档页的**交互动作**(数据在服务端渲染,这里只管「页面之外」的动作)。
*
* 拆出来的原因:归档详情页改为 Server Component(只读内容必须能被 SSR 直出,
* 否则不带 JS 的抓取/分享预览看不到「选股条件」与「交易执行依据」);
* 而删除、导出、折叠 spec 这些必须有事件处理,就集中放在这个客户端组件里。
*
* 删除的确认文案必须写清后果:结果只存归档一份(`job.result_json` 对新记录为 NULL,
* `GET /api/jobs/{id}` 是从归档回读的),所以删归档 = 该次回测结果彻底消失。
* 先前的文案写成「不影响作业记录」,那是**误导**。
*/
import { useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { apiDelete, apiGetText } from "@/lib/api";
import { Banner, Btn, Card } from "@/components/ui";
/**
* 刻意只收 `id` / `spec`(**不接收完整 result**):归档结果可达数 MB,而服务端已经把它
* 传给图表组件了;再传一份给本组件会让 RSC 载荷翻倍。导出时按需拉取。
*/
export function ArchiveActions({ id, spec }: { id: string; spec: unknown }) {
const router = useRouter();
const [deleting, setDeleting] = useState(false);
const [error, setError] = useState("");
const [showSpec, setShowSpec] = useState(false);
async function downloadJson() {
setError("");
try {
const text = await apiGetText(`/experiments/${encodeURIComponent(id)}`);
const blob = new Blob([text], { type: "application/json" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `${id}-archive.json`;
a.click();
URL.revokeObjectURL(url);
} catch (e) {
setError(`导出失败:${(e as Error).message}`);
}
}
async function remove() {
const ok = window.confirm(
`删除归档 ${id}?\n\n` +
`· 删除后这份快照(结果 + spec)无法再查看,也不可恢复;\n` +
`· 结果只存归档这一份 —— 删除后连该次执行的作业记录也读不回结果\n` +
` (完整存档上线前的早期归档在作业表里另有副本,但不应依赖);\n` +
`· 想留底请先点左侧「导出完整 JSON」。\n\n确认删除?`
);
if (!ok) return;
setDeleting(true);
setError("");
try {
await apiDelete(`/experiments/${encodeURIComponent(id)}`);
router.push("/experiments");
} catch (e) {
setError((e as Error).message);
setDeleting(false);
}
}
return (
<>
{error ? <Banner tone="error">{error}</Banner> : null}
<div className="row" style={{ gap: 8, marginBottom: 12, flexWrap: "wrap" }}>
<Link href="/experiments" className="btn">
<span>实验对比</span>
</Link>
<Link href={`/experiments?exp=${encodeURIComponent(id)}`} className="btn">
<span>加入对比</span>
</Link>
<Link
href={`/backtest?from_experiment=${encodeURIComponent(id)}`}
className="btn btn--primary"
title="在回测页复用这份归档的参数(并直接载入该归档的结果),可微调后重跑"
>
<span>以此参数再跑</span>
</Link>
<Btn size="sm" icon="download" onClick={downloadJson}>
导出完整 JSON
</Btn>
<Btn size="sm" icon="book" onClick={() => setShowSpec((v) => !v)}>
{showSpec ? "收起原始 spec" : "查看原始 spec(JSON)"}
</Btn>
<Btn size="sm" icon="trash" variant="danger" loading={deleting} disabled={deleting} onClick={remove}>
删除归档
</Btn>
</div>
{showSpec ? (
<Card title="归档 spec(JSON 原文,复现依据)">
<pre className="mono codeblock">{JSON.stringify(spec ?? {}, null, 2)}</pre>
</Card>
) : null}
</>
);
}