Files
qlib/frontend/web/components/BacktestResultView.tsx
T
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

657 lines
23 KiB
TypeScript
Raw 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";
/**
* 回测结果视图(**回测页**与**实验归档页**共用同一组件)。
*
* 共用是刻意的:归档要能被「往复查看」,就必须和刚跑完时看到的形态完全一致——
* 两处各写一套迟早会漂移,导致「归档里少了一张图/多了一列」这种隐性不一致。
*
* 三处模式差异用 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;
}