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 逐类验证归档页)。
This commit is contained in:
Simon
2026-09-20 07:31:04 +08:00
parent 7e15b7251e
commit 23972e7063
112 changed files with 17908 additions and 3893 deletions
@@ -0,0 +1,656 @@
"use client";
/**
* 回测结果视图(**回测页**与**实验归档页**共用同一组件)。
*
* 共用是刻意的:归档要能被「往复查看」,就必须和刚跑完时看到的形态完全一致——
* 两处各写一套迟早会漂移,导致「归档里少了一张图/多了一列」这种隐性不一致。
*
* 三处模式差异用 props 表达:
* - `name`:策略名(运行时的策略);归档页传归档里记录的策略名(若有)。
* - `archive`:传入后额外展示归档元数据(实验 id / 数据版本 / 代码版本 / 归档时间)
* 与**归档完整度**(`result.archive_meta.truncated` 为真时显式提示曲线被裁剪,不静默)。
*/
import { useMemo, useRef, useState } from "react";
import {
Card,
Pill,
Btn,
Empty,
BacktestMetrics,
MonthlyReturnsTable,
UnimplementedNote,
} from "@/components/ui";
import { LwChart, type LwMarker, type LwSeries } from "@/components/charts/LwChart";
import { CHART, fmtNum, fmtPct } from "@/components/charts/theme";
import Link from "next/link";
import { SymbolLink, useSymbolNames } from "@/lib/symbols";
import { adjustLabel } from "@/lib/labels";
import type { ActionRecord, BacktestResult, SymbolCurve } from "@/lib/types";
export interface ArchiveInfo {
id: string;
created_at?: string | null;
code_version?: string | null;
data_version?: string | null;
job_id?: string | null;
}
function pad(n: number): string {
return String(n).padStart(2, "0");
}
function fmtDateTime(v?: string | null): string {
if (!v) return "—";
const d = new Date(v);
if (Number.isNaN(d.getTime())) return v;
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
}
export function BacktestResultView({
result,
name,
archive,
}: {
result: BacktestResult;
name?: string;
archive?: ArchiveInfo;
}) {
const s = result.summary;
const last = result.positions.at(-1);
const holdings = result.positions.filter((p) => p.date === last?.date);
const curves = result.symbol_curves ?? [];
const [active, setActive] = useState<string>(curves[0]?.symbol ?? "");
const [curveQuery, setCurveQuery] = useState("");
// 老实验(后端填充 name 之前归档的结果)里 curve.name 为空:
// 名称统一从 useSymbolNames 缓存取,保证「有代码必有名称」在任何历史结果上都成立
const { names: nameCache } = useSymbolNames();
const nameOf = (c: SymbolCurve) => c.name ?? nameCache[c.symbol] ?? "";
const topRef = useRef<HTMLDivElement | null>(null);
// 组合净值上的买卖点:同一日的成交合并成一个标记,落在当日净值上
const equitySeries = useMemo<LwSeries[]>(
() => [
{
key: "equity",
label: "组合净值(元)",
type: "area",
color: CHART.pos,
data: result.equity_curve.map((p) => ({ time: p.date, value: p.value })),
lastValueVisible: true,
},
],
[result.equity_curve]
);
const equityMarkers = useMemo<LwMarker[]>(() => {
const byDate = new Map<string, { BUY: boolean; SELL: boolean }>();
for (const f of result.fills ?? []) {
const cur = byDate.get(f.date) ?? { BUY: false, SELL: false };
cur[f.signal] = true;
byDate.set(f.date, cur);
}
const equity = new Set(result.equity_curve.map((p) => p.date));
const out: LwMarker[] = [];
for (const [d, kinds] of byDate) {
if (!equity.has(d)) continue;
if (kinds.BUY) out.push({ time: d, kind: "BUY", text: "买" });
if (kinds.SELL) out.push({ time: d, kind: "SELL", text: "卖" });
}
return out;
}, [result.fills, result.equity_curve]);
const filteredCurves = useMemo(() => {
const q = curveQuery.trim().toLowerCase();
if (!q) return curves;
return curves.filter(
(c) => c.symbol.toLowerCase().includes(q) || nameOf(c).toLowerCase().includes(q)
);
// nameOf 只依赖 nameCache 与 c,二者都在依赖里
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [curves, curveQuery, nameCache]);
const activeCurve = curves.find((c) => c.symbol === active) ?? filteredCurves[0] ?? curves[0];
const buyDays = equityMarkers.filter((m) => m.kind === "BUY").length;
const sellDays = equityMarkers.filter((m) => m.kind === "SELL").length;
const SECTIONS = [
{ id: "sec-equity", label: "整体收益" },
{ id: "sec-symbols", label: "个股曲线" },
{ id: "sec-monthly", label: "月度/年度" },
{ id: "sec-holdings", label: "持仓" },
{ id: "sec-trades", label: "成交明细" },
];
return (
<>
<div ref={topRef} />
<div className="sticky-bar" style={{ gap: 10 }}>
<b style={{ fontSize: 13 }}>结果分区</b>
{SECTIONS.map((s) => (
<a key={s.id} href={`#${s.id}`} className="btn btn--sm">
<span>{s.label}</span>
</a>
))}
<span className="hint">共 {result.equity_curve.length} 个交易日 · {result.trades.length} 笔成交</span>
</div>
{archive ? <ArchiveMetaBar archive={archive} /> : null}
<ArchiveCompletenessNote result={result} archive={archive} />
<div className="between" style={{ margin: "6px 0 14px" }}>
<div className="row" style={{ flexWrap: "wrap" }}>
<Pill tone="pos" icon="check">
完成
</Pill>
{name ? (
<Pill tone="violet" icon="book">
{name}
</Pill>
) : null}
<Pill tone="violet" icon="target">
{poolLabel(result)}
</Pill>
<Pill>{adjustLabelFromSnapshot(result.config_snapshot)} 口径</Pill>
{stBasisNote(result) && (
<Pill tone="warn" icon="alert">
{stBasisNote(result)}
</Pill>
)}
<Pill>
{s.start} ~ {s.end}
</Pill>
</div>
<div className="row" style={{ fontSize: 22, fontWeight: 700 }}>
总收益 <SignedText value={s.total_return_pct} />
</div>
</div>
<BacktestMetrics s={s} />
<div className="chart-grid" id="sec-equity">
<Card
icon="chartLine"
title="整体收益趋势(含买卖点)"
tools={
<Pill tone="pos">
期末 {fmtNum(s.final_equity)} · 买入 {buyDays} 日 / 卖出 {sellDays} 日
</Pill>
}
>
<LwChart
series={equitySeries}
markers={equityMarkers}
height={320}
valueFormat={(v) => fmtNum(v)}
ariaLabel="组合净值曲线与买卖点"
/>
<div className="hint" style={{ marginTop: 6 }}>
▲ 绿 = 当日有买入成交,▼ 红 = 当日有卖出成交;点位取当日组合净值。
悬停可看任意日期的净值;拖动/滚轮可缩放区间,双击图例可临时隐藏曲线。
</div>
</Card>
<Card
icon="chartLine"
title="回撤(%)"
tools={<Pill tone="neg">最大 {s.max_drawdown_pct.toFixed(2)}%</Pill>}
>
<LwChart
series={[
{
key: "dd",
label: "回撤(%)",
type: "area",
color: CHART.neg,
data: result.drawdown.map((p) => ({ time: p.date, value: p.value })),
lastValueVisible: true,
},
]}
height={320}
valueFormat={(v) => `${v.toFixed(2)}%`}
zeroLine
ariaLabel="回撤曲线"
/>
</Card>
</div>
<Card
id="sec-symbols"
icon="target"
title="个股收益率趋势(持仓期累计收益,含买卖点)"
tools={
<div className="row" style={{ gap: 8 }}>
<input
className="input"
style={{ width: 180 }}
placeholder="搜索代码或名称"
value={curveQuery}
onChange={(e) => setCurveQuery(e.target.value)}
aria-label="搜索个股"
/>
<Pill>{curves.length} 只有成交</Pill>
</div>
}
>
{curves.length === 0 ? (
<Empty title="无个股曲线" hint="回测区间内没有产生成交。" />
) : (
<>
<div className="row" style={{ gap: 8, marginBottom: 10, flexWrap: "wrap" }}>
<select
className="input"
style={{ width: 280 }}
value={activeCurve?.symbol ?? ""}
onChange={(e) => setActive(e.target.value)}
>
{filteredCurves.map((c) => {
const nm = nameOf(c);
return (
<option key={c.symbol} value={c.symbol}>
{c.symbol} {nm || "—"}({fmtPct(c.final_return_pct)})
</option>
);
})}
</select>
<SymbolLink
symbol={activeCurve?.symbol ?? ""}
name={activeCurve ? nameOf(activeCurve) : undefined}
/>
<span className="hint">
曲线口径:该股被持有期间按日复利累计(建仓当日为 0%);未持有期间不绘制,
分段间以直线连接,请以买卖点区分持仓区间。
</span>
</div>
{activeCurve ? <SymbolCurveChart curve={activeCurve} /> : null}
<div className="table-wrap" style={{ marginTop: 12 }}>
<table className="tbl">
<thead>
<tr>
<th>股票</th>
<th>持仓期累计收益</th>
<th>买点</th>
<th>卖点</th>
<th>操作</th>
</tr>
</thead>
<tbody>
{filteredCurves.map((c) => (
<tr key={c.symbol} className={c.symbol === activeCurve?.symbol ? "row-active" : ""}>
<td className="sym-cell">
<SymbolLink symbol={c.symbol} name={c.name} />
</td>
<td className={c.final_return_pct >= 0 ? "tone-pos" : "tone-neg"}>
{c.final_return_pct.toFixed(2)}%
</td>
<td className="mono dim">
{(c.marks ?? []).filter((m) => m.signal === "BUY").length}
</td>
<td className="mono dim">
{(c.marks ?? []).filter((m) => m.signal === "SELL").length}
</td>
<td>
<Btn size="sm" onClick={() => setActive(c.symbol)}>
查看曲线
</Btn>
</td>
</tr>
))}
</tbody>
</table>
</div>
</>
)}
</Card>
<Card id="sec-monthly" icon="calendar" title="月度收益(%)">
<LwChart
series={[
{
key: "monthly",
label: "月度收益(%)",
type: "bar",
color: CHART.accent,
data: result.monthly_returns.map((m) => ({
time: `${m.year}-${String(m.month).padStart(2, "0")}-01`,
value: m.return_pct,
})),
},
]}
height={200}
valueFormat={(v) => `${v.toFixed(2)}%`}
zeroLine
legend={false}
ariaLabel="月度收益柱状图"
/>
<details style={{ marginTop: 8 }}>
<summary className="hint" style={{ cursor: "pointer" }}>
查看月度收益明细表
</summary>
<MonthlyReturnsTable rows={result.monthly_returns} />
</details>
</Card>
<Card
id="sec-holdings"
icon="target"
title={`最新持仓 · ${last?.date ?? "—"}`}
tools={<Pill>{holdings.length} 只</Pill>}
>
{holdings.length === 0 ? (
<Empty title="无持仓记录" hint="回测区间内没有产生持仓。" />
) : (
<div className="table-wrap">
<table className="tbl">
<thead>
<tr>
<th>股票</th>
<th>权重</th>
</tr>
</thead>
<tbody>
{holdings.map((p) => (
<tr key={p.symbol}>
<td className="sym-cell">
<SymbolLink symbol={p.symbol} name={p.name} />
</td>
<td className="mono">{(p.weight * 100).toFixed(2)}%</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</Card>
{result.yearly_returns.length ? (
<Card
icon="calendar"
title="年度收益(%)"
tools={
<Pill>
均值{" "}
{(
result.yearly_returns.reduce((a, y) => a + y.return_pct, 0) /
result.yearly_returns.length
).toFixed(2)}
%
</Pill>
}
>
<div className="chips">
{result.yearly_returns.map((y) => (
<span className="chip" key={y.year}>
<b>{y.year}</b>
<span className={y.return_pct >= 0 ? "tone-pos" : "tone-neg"}>
{y.return_pct.toFixed(2)}%
</span>
</span>
))}
</div>
</Card>
) : null}
{result.trades.length ? (
<Card
id="sec-trades"
icon="scale"
title={`成交明细 · ${result.trades.length} 笔`}
tools={<Pill>{result.turnover_pct.toFixed(2)}% 累计换手</Pill>}
>
<div className="table-wrap">
<table className="tbl">
<thead>
<tr>
<th>买入日</th>
<th>卖出日</th>
<th>股票</th>
<th>买价</th>
<th>卖价</th>
<th>收益</th>
</tr>
</thead>
<tbody>
{result.trades.map((t) => (
<tr key={`${t.symbol}-${t.entry_date}-${t.exit_date}`}>
<td className="mono dim">{t.entry_date}</td>
<td className="mono dim">{t.exit_date}</td>
<td className="sym-cell">
<SymbolLink symbol={t.symbol} name={t.name} />
</td>
<td className="mono">{t.entry_price?.toFixed(2) ?? "-"}</td>
<td className="mono">{t.exit_price?.toFixed(2) ?? "-"}</td>
<td className={t.return_pct >= 0 ? "tone-pos" : "tone-neg"}>
{t.return_pct.toFixed(2)}%
</td>
</tr>
))}
</tbody>
</table>
</div>
</Card>
) : null}
<NotFilledCard signals={result.signal_history ?? []} />
<UnimplementedNote items={result.unimplemented} />
</>
);
}
/** 未成交意图(涨停/停牌/顺延)透明化,避免「信号有了却没买」无法解释。 */
function NotFilledCard({ signals }: { signals: ActionRecord[] }) {
const rejects = signals.filter((a) => !a.filled && a.reject_reason);
if (rejects.length === 0) return null;
const byReason = new Map<string, number>();
for (const r of rejects) {
const key = (r.reject_reason ?? "").replace(/,?顺延.*$/, "(顺延)");
byReason.set(key, (byReason.get(key) ?? 0) + 1);
}
return (
<Card icon="info" title={`未成交意图 · ${rejects.length} 条`}>
<div className="chips">
{[...byReason.entries()].map(([reason, n]) => (
<span className="chip" key={reason}>
{reason} <b>{n}</b>
</span>
))}
</div>
<div className="hint" style={{ marginTop: 8 }}>
涨停/停牌导致的未成交按「信号已记录、成交未发生」处理(v3 §20.3):顺延买入会在之后首个
可成交交易日按收盘价成交;到下一次调仓仍未成交则作废。
</div>
</Card>
);
}
function SymbolCurveChart({ curve }: { curve: SymbolCurve }) {
const valueByDate = useMemo(() => new Map(curve.points.map((p) => [p.date, p.value])), [curve]);
const markers = useMemo<LwMarker[]>(
() =>
(curve.marks ?? [])
.filter((a) => valueByDate.has(a.date))
.map((a) => ({
time: a.date,
kind: a.signal,
text: a.signal === "BUY" ? "买" : "卖",
})),
[curve, valueByDate]
);
return (
<LwChart
series={[
{
key: "sym",
label: `${curve.symbol} 持仓期累计收益(%)`,
type: "area",
color: CHART.accent,
data: curve.points.map((p) => ({ time: p.date, value: p.value })),
lastValueVisible: true,
},
]}
markers={markers}
height={300}
valueFormat={(v) => `${v.toFixed(2)}%`}
zeroLine
ariaLabel={`${curve.symbol} 持仓期收益曲线与买卖点`}
/>
);
}
function poolLabel(result: BacktestResult): string {
const sel = result.config_snapshot?.selection as
| { top_n?: number; hold_top_x?: number | null }
| undefined;
if (!sel) return "候选池 —";
return `候选池 ${sel.top_n ?? "—"} → 持仓 ${sel.hold_top_x ?? sel.top_n ?? "—"}`;
}
/**
* ST(风险警示)剔除口径提示。
*
* 后端逐择股日按**当时名称**判定 exclude_st(依赖 `sync namechange`);未同步名称历史时
* 回退「最新名称快照」,会把「曾为高股息、后来才 ST/退市」的股息陷阱样本整段排除,
* 收益被高估(实测案例 +24.86% → +35.71%)。此处把口径显式暴露给使用者,避免误读。
*/
function stBasisNote(result: BacktestResult): string | null {
const basis = result.config_snapshot?.price_basis as
| { name_basis?: { point_in_time?: boolean } | null }
| undefined;
const nb = basis?.name_basis;
if (!nb) return null; // 未启用 exclude_st
return nb.point_in_time ? "ST 按时点名称判定" : "ST 用最新名称(可能高估)";
}
function adjustLabelFromSnapshot(snapshot: Record<string, unknown>): string {
const basis = snapshot?.price_basis as { adjust_mode?: string } | undefined;
const mode = basis?.adjust_mode ?? "none";
return mode === "hfq" ? "后复权" : mode === "qfq" ? "前复权" : "不复权";
}
function SignedText({ value }: { value: number }) {
const cls = value >= 0 ? "tone-pos" : "tone-neg";
return (
<span className={cls}>
{value > 0 ? "+" : ""}
{value.toFixed(2)}%
</span>
);
}
/**
* 归档元数据条:**归档能否被信任地复现,取决于这几项**,所以放在最显眼的位置。
* - 代码版本:跑出这个结果时的 git short rev
* - 数据版本:当时的数据快照指纹(由后端在归档时写入;老归档可能为空 → 如实显示「—」)
* - 实验 / 作业 id:可追到执行记录
*/
function ArchiveMetaBar({ archive }: { archive: ArchiveInfo }) {
return (
<div className="archive-bar">
<Pill tone="violet" icon="archive">
归档 {archive.id}
</Pill>
<span className="hint">
归档时间 <b className="mono">{fmtDateTime(archive.created_at)}</b> · 代码版本{" "}
<b className="mono">{archive.code_version ?? "—"}</b> · 数据版本{" "}
<b className="mono">{archive.data_version ?? "未记录"}</b>
{archive.job_id ? (
<>
{" "}
· 作业 <b className="mono">{archive.job_id}</b>
</>
) : null}
</span>
</div>
);
}
/**
* 归档完整度提示。
*
* `archive_meta.truncated` 为真表示:归档时结果 JSON 超过安全预算,个股曲线被裁剪
* (后端会在 `unimplemented` 里同时留下说明)。**必须显式告知**,否则用户会以为
* 「这只股票没被买过」,而实际只是没存下来。
*/
function ArchiveCompletenessNote({
result,
archive,
}: {
result: BacktestResult;
archive?: ArchiveInfo;
}) {
const meta = result.archive_meta as
| { curves_stored?: number; curves_total?: number; truncated?: boolean; budget_chars?: number }
| undefined;
const legacyNote = (result.unimplemented ?? []).find((u) => u.includes("个股收益曲线"));
// 情况 1:归档带完整度元数据(本轮之后产生的归档)
if (meta && meta.curves_total !== undefined) {
const total = meta.curves_total ?? 0;
const stored = meta.curves_stored ?? 0;
return (
<div className="archive-bar">
{meta.truncated ? (
<Pill tone="warn" icon="alert">
归档不完整
</Pill>
) : (
<Pill tone="pos" icon="check">
归档完整
</Pill>
)}
<span className="hint">
个股曲线已存 <b className="mono">{stored}</b> / 期内持有 <b className="mono">{total}</b> 只
{meta.truncated
? "(超出结果体积预算被裁剪:请在下方用「成交明细 / 未成交意图」查缺失个股,或缩短区间后重跑以得到完整曲线)"
: "(全部持有过的股票都可在此查看)"}
</span>
</div>
);
}
// 情况 2:老归档没有元数据,但结果里留了截断说明 → **不能装作完整**
if (legacyNote) {
const held = /期内共持有\s*(\d+)\s*只/.exec(legacyNote)?.[1];
const kept = /最大的\s*(\d+)\s*只/.exec(legacyNote)?.[1];
return (
<div className="archive-bar">
<Pill tone="warn" icon="alert">
归档不完整
</Pill>
<span className="hint">
该归档产生于「完整存档」上线之前:个股曲线只有 <b className="mono">{kept ?? "60"}</b> 只(期内共持有{" "}
<b className="mono">{held ?? "更多"}</b> 只),换手但收益排名不靠前的个股在这里看不到曲线。
成交明细 / 未成交意图仍是完整的。要看全部个股曲线,请
{archive ? (
<>
{" "}
<Link href={`/backtest?from_experiment=${encodeURIComponent(archive.id)}`} className="btn btn--sm">
<span>以此参数重跑</span>
</Link>{" "}
生成一份完整归档。
</>
) : (
"以此参数重跑一次(新归档将保存全部持有过的个股曲线)。"
)}
</span>
</div>
);
}
// 情况 3:既无元数据也无截断说明 → 无从判断,不猜(只在老归档上出现)
if (archive) {
return (
<div className="archive-bar">
<Pill icon="info">
完整度未标注
</Pill>
<span className="hint">
该归档早于「归档完整度元数据」上线,无法确认个股曲线是否被裁剪过
(成交明细 / 未成交意图始终是完整的)。
</span>
</div>
);
}
return null;
}