用户要求:「所有买卖点详细说明买卖理由,用数据说话」「回测图上增加因子相关曲线
(买卖依据是股息率,就加股息率曲线)」「所有曲线能弹出新页面放大」。
一、买卖理由(后端产出结构化数据,前端只展示)
- 新增 `quant/trade_reasons.py`:封闭词表 + 文案构造器,组合引擎与单策略引擎共用,
避免两个引擎对同一件事写出两种说法。理由里带**引擎当时的真实数字**:
综合分名次/候选数/综合分/各因子原始值/持有交易日/预算与最低佣金/涨停比值等。
- 买入:按名次建仓、顺延成交、涨停未买、停牌未买、现金不足、不足最低佣金;
卖出:跌出 TopN(含第几名掉出)、被股票池过滤(与「跌出 TopN」分开写)、
超 Tmax 强制了结、Tmin 保护暂留、停牌/跌停顺延。
- `ActionRecord.reason` 覆盖**成交与未成交**全部买卖点(原 `reject_reason` 保留不动,
老归档仍可读);`Trade.entry_reason / exit_reason` 跟着成交记录走。
- 名次来自调仓日完整排名(新增 `_ranked_by_day`),拿不到名次时如实写「未给出名次」,
绝不编造一个名次填进去。
- 未成交明细不再只写执行层原因:把「为什么选中它、当时各因子多少」一并给出。
二、因子曲线
- `FactorCurve`:每个策略因子一条曲线,值为**当日持仓按市值加权平均的原始值**
(不做 z-score、不按方向取反,空仓日不落点、不插值、不用 0 填充),并带
label/direction/unit 供界面说明口径;`FactorDef/FactorTemplate` 新增 `unit`
(股息率 %、量比/接近新高 倍数、动量等 小数),11 个内置因子实例已逐一核对。
- 归档体积预算照旧按整包计量,无需改迁移。
三、界面
- 结果页新增「买卖说明」区块:全部买卖点 + 理由 + 数字标签,支持方向/成交状态/关键字
筛选与日期排序;成交明细表加「为什么买 / 为什么卖」两列;新增「因子曲线」区块,
每条曲线标出组合成交日,直接对照「买卖发生在什么水平」。
- 「新页面放大」:每条曲线(净值/回撤/因子/个股/月度)都能开 `/charts/{归档id}?s=...`
整页看大图;放大页是 Server Component,数据从归档直出,URL 可分享且与归档一致。
未归档的结果如实说明「未归档,无法放大」,不给坏链接。
- 数字格式与后端 `f"{v:.4f}"` 同规则(四舍六入五成双):修掉 0.03125 在理由原文里
显示 0.0312、旁边标签显示 0.0313 的不一致(17 组边界值与 Python 逐一比对一致)。
- `/factors/compose` 结果区改用同一个 `BacktestResultView`,两处口径不会再漂移。
验证:
- 新增 `tests/test_trade_reasons.py` 8 条(买入数字、跌出 TopN 名次、不在候选池、
Tmax、Tmin 暂留、涨停未成交、因子曲线加权值、空仓不落点);后端 510 条全过,ruff clean。
- 真实数据端到端:`/api/combos/run` 6 个月高股息组合(EXP-8EA2819B)13 个买卖点
100% 带理由与数字,因子曲线 dividend_yield 117 点、单位 %;
`scripts/verify_backtest_page_contract.py`(4 年、301 个买卖点、140 笔成交)扩展断言
理由词表/名次/因子值/曲线单调性后通过。
- 浏览器实测:归档详情页与放大页 `/charts/...?s=factor:dividend_yield` 等 5 种曲线
全部 200 渲染,截图确认表格与曲线数值正确。
719 lines
26 KiB
TypeScript
719 lines
26 KiB
TypeScript
"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;
|
||
}
|