汇总三轮未提交的开发(每轮均在本机 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 逐类验证归档页)。
99 lines
4.0 KiB
TypeScript
99 lines
4.0 KiB
TypeScript
"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}
|
||
</>
|
||
);
|
||
} |