Files
qlib/frontend/web/components/BacktestResultView.tsx
Simon 2a88ca6076 fix(web): 去掉界面文案里的字面 **(hint 不走 Markdown)
反馈条与因子曲线的口径说明里写了 `**加粗**`,但这些位置是纯文本 hint,页面会把星号
原样显示出来(放大页截图逐字核对时发现)。改为中文引号「」,语义不变、不再露出 Markdown 语法。
2026-10-01 18:13:00 +08:00

719 lines
26 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";
/**
* 回测结果视图(**回测页**与**实验归档页**共用同一组件)。
*
* 共用是刻意的:归档要能被「往复查看」,就必须和刚跑完时看到的形态完全一致——
* 两处各写一套迟早会漂移,导致「归档里少了一张图/多了一列」这种隐性不一致。
*
* 三处模式差异用 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";
import { reasonLabel } from "@/lib/types";
import { ChartPopoutLink } from "@/components/ChartPopoutLink";
import { TradeReasonsCard, type FactorLabels } from "@/components/TradeReasons";
import {
drawdownSeries,
equitySeries,
equityValueFormat,
factorCurveNote,
factorSeries,
factorValueFormat,
portfolioMarkers,
symbolMarkers,
symbolSeries,
} from "@/lib/chartSeries";
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);
// 曲线序列统一走 lib/chartSeries(与「新页面放大」共用同一套口径与格式)
const equityData = useMemo<LwSeries[]>(() => equitySeries(result), [result]);
const equityMarkers = useMemo<LwMarker[]>(
() => portfolioMarkers(result.fills, new Set(result.equity_curve.map((p) => p.date))),
[result]
);
// 因子键 → 展示名:买卖理由里的因子值要用中文名,不能只甩引擎键
const factorLabels = useMemo<FactorLabels>(() => {
const out: FactorLabels = {};
for (const f of result.factor_curves ?? []) out[f.name] = f.label;
return out;
}, [result.factor_curves]);
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-factors", label: "因子曲线" },
{ id: "sec-symbols", label: "个股曲线" },
{ id: "sec-monthly", label: "月度/年度" },
{ id: "sec-holdings", label: "持仓" },
{ id: "sec-trades", label: "成交明细" },
{ id: "sec-reasons", 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={
<div className="row" style={{ gap: 6 }}>
<Pill tone="pos">
期末 {fmtNum(s.final_equity)} · 买入 {buyDays} 日 / 卖出 {sellDays} 日
</Pill>
<ChartPopoutLink archiveId={archive?.id} series="equity" />
</div>
}
>
<LwChart
series={equityData}
markers={equityMarkers}
height={320}
valueFormat={equityValueFormat}
ariaLabel="组合净值曲线与买卖点"
/>
<div className="hint" style={{ marginTop: 6 }}>
▲ 绿 = 当日有买入成交,▼ 红 = 当日有卖出成交;点位取当日组合净值。
悬停可看任意日期的净值;拖动/滚轮可缩放区间,双击图例可临时隐藏曲线。
</div>
</Card>
<Card
icon="chartLine"
title="回撤(%)"
tools={
<div className="row" style={{ gap: 6 }}>
<Pill tone="neg">最大 {s.max_drawdown_pct.toFixed(2)}%</Pill>
<ChartPopoutLink archiveId={archive?.id} series="drawdown" />
</div>
}
>
<LwChart
series={drawdownSeries(result)}
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 input--search"
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 input--picker"
aria-label="选择要查看的个股"
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>
<ChartPopoutLink
archiveId={archive?.id}
series={`sym:${activeCurve?.symbol ?? ""}`}
label="放大当前个股"
/>
</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>
<FactorCurvesCard result={result} fills={result.fills} archiveId={archive?.id} />
<Card
id="sec-monthly"
icon="calendar"
title="月度收益(%)"
tools={<ChartPopoutLink archiveId={archive?.id} series="monthly" />}
>
<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>
<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>
<td className="reason-brief" title={t.entry_reason?.text ?? undefined}>
<span>{reasonLabel(t.entry_reason?.code)}</span>
</td>
<td className="reason-brief" title={t.exit_reason?.text ?? undefined}>
<span>{reasonLabel(t.exit_reason?.code)}</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
</Card>
) : null}
<NotFilledCard signals={result.signal_history ?? []} />
<TradeReasonsCard signals={result.signal_history ?? []} factorLabels={factorLabels} />
<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 }) {
return (
<LwChart
series={symbolSeries(curve)}
markers={symbolMarkers(curve)}
height={300}
valueFormat={(v) => `${v.toFixed(2)}%`}
zeroLine
ariaLabel={`${curve.symbol} 持仓期收益曲线与买卖点`}
/>
);
}
/**
* 因子曲线:策略里每个因子一张图(**原始值**,不做 z-score)。
*
* 用户要求:「本因子的买卖依据是股息率,那么要增加股息率曲线」。这里把**同一批成交日**
* 标在因子曲线上,于是能一眼看出「买在什么水平、卖在什么水平」,而不是只看净值曲线
* 猜原因。口径(持仓加权平均、不按方向取反、空仓不落点)写在每张图下方。
*/
function FactorCurvesCard({
result,
fills,
archiveId,
}: {
result: BacktestResult;
fills?: ActionRecord[];
archiveId?: string | null;
}) {
const curves = result.factor_curves ?? [];
if (!curves.length) return null;
return (
<Card
id="sec-factors"
icon="layers"
title={`因子曲线 · ${curves.length} 个(买卖依据的水平)`}
tools={<Pill tone="violet">持仓加权平均原始值</Pill>}
>
<div className="hint" style={{ marginBottom: 10 }}>
每个因子一条曲线:值为当日「持仓股票按市值加权平均」的因子原始值,用来回答
「买入时这个因子处于什么水平、卖出时又变到哪」。图上 ▲/▼ 是「组合的成交日」(同一套买卖点),
因此能直接对照「因子在什么水平触发买卖」。曲线未做 z-score、也未按方向取反;
低为好的因子(方向标注为「越低越好」)曲线升高不等于更好。
</div>
<div className="chart-grid">
{curves.map((c, i) => {
const dates = new Set(c.points.map((p) => p.date));
return (
<Card
key={c.name}
title={c.label}
tools={
<div className="row" style={{ gap: 6 }}>
<Pill tone={c.direction === "lower_is_better" ? "warn" : "pos"}>
{c.direction === "lower_is_better" ? "越低越好" : "越高越好"}
</Pill>
<ChartPopoutLink archiveId={archiveId} series={`factor:${c.name}`} />
</div>
}
>
<LwChart
series={[factorSeries(c, i)]}
markers={portfolioMarkers(fills, dates)}
height={280}
valueFormat={factorValueFormat(c)}
ariaLabel={`因子 ${c.label} 的持仓加权曲线与买卖点`}
/>
<div className="hint" style={{ marginTop: 6 }}>
{factorCurveNote(c)}
</div>
</Card>
);
})}
</div>
</Card>
);
}
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;
}