feat(backtest): 买卖点理由(用数据说话)+ 因子曲线 + 曲线新页面放大
用户要求:「所有买卖点详细说明买卖理由,用数据说话」「回测图上增加因子相关曲线
(买卖依据是股息率,就加股息率曲线)」「所有曲线能弹出新页面放大」。
一、买卖理由(后端产出结构化数据,前端只展示)
- 新增 `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 渲染,截图确认表格与曲线数值正确。
This commit is contained in:
@@ -29,6 +29,20 @@ 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;
|
||||
@@ -70,37 +84,19 @@ export function BacktestResultView({
|
||||
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]
|
||||
// 曲线序列统一走 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 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: "卖" });
|
||||
}
|
||||
// 因子键 → 展示名:买卖理由里的因子值要用中文名,不能只甩引擎键
|
||||
const factorLabels = useMemo<FactorLabels>(() => {
|
||||
const out: FactorLabels = {};
|
||||
for (const f of result.factor_curves ?? []) out[f.name] = f.label;
|
||||
return out;
|
||||
}, [result.fills, result.equity_curve]);
|
||||
}, [result.factor_curves]);
|
||||
|
||||
const filteredCurves = useMemo(() => {
|
||||
const q = curveQuery.trim().toLowerCase();
|
||||
@@ -119,10 +115,12 @@ export function BacktestResultView({
|
||||
|
||||
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 (
|
||||
@@ -175,16 +173,19 @@ export function BacktestResultView({
|
||||
icon="chartLine"
|
||||
title="整体收益趋势(含买卖点)"
|
||||
tools={
|
||||
<Pill tone="pos">
|
||||
期末 {fmtNum(s.final_equity)} · 买入 {buyDays} 日 / 卖出 {sellDays} 日
|
||||
</Pill>
|
||||
<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={equitySeries}
|
||||
series={equityData}
|
||||
markers={equityMarkers}
|
||||
height={320}
|
||||
valueFormat={(v) => fmtNum(v)}
|
||||
valueFormat={equityValueFormat}
|
||||
ariaLabel="组合净值曲线与买卖点"
|
||||
/>
|
||||
<div className="hint" style={{ marginTop: 6 }}>
|
||||
@@ -195,19 +196,15 @@ export function BacktestResultView({
|
||||
<Card
|
||||
icon="chartLine"
|
||||
title="回撤(%)"
|
||||
tools={<Pill tone="neg">最大 {s.max_drawdown_pct.toFixed(2)}%</Pill>}
|
||||
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={[
|
||||
{
|
||||
key: "dd",
|
||||
label: "回撤(%)",
|
||||
type: "area",
|
||||
color: CHART.neg,
|
||||
data: result.drawdown.map((p) => ({ time: p.date, value: p.value })),
|
||||
lastValueVisible: true,
|
||||
},
|
||||
]}
|
||||
series={drawdownSeries(result)}
|
||||
height={320}
|
||||
valueFormat={(v) => `${v.toFixed(2)}%`}
|
||||
zeroLine
|
||||
@@ -261,6 +258,11 @@ export function BacktestResultView({
|
||||
曲线口径:该股被持有期间按日复利累计(建仓当日为 0%);未持有期间不绘制,
|
||||
分段间以直线连接,请以买卖点区分持仓区间。
|
||||
</span>
|
||||
<ChartPopoutLink
|
||||
archiveId={archive?.id}
|
||||
series={`sym:${activeCurve?.symbol ?? ""}`}
|
||||
label="放大当前个股"
|
||||
/>
|
||||
</div>
|
||||
{activeCurve ? <SymbolCurveChart curve={activeCurve} /> : null}
|
||||
<div className="table-wrap" style={{ marginTop: 12 }}>
|
||||
@@ -303,7 +305,14 @@ export function BacktestResultView({
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<Card id="sec-monthly" icon="calendar" title="月度收益(%)">
|
||||
<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={[
|
||||
{
|
||||
@@ -408,6 +417,8 @@ export function BacktestResultView({
|
||||
<th>买价</th>
|
||||
<th>卖价</th>
|
||||
<th>收益</th>
|
||||
<th>为什么买</th>
|
||||
<th>为什么卖</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -423,6 +434,12 @@ export function BacktestResultView({
|
||||
<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>
|
||||
@@ -433,6 +450,8 @@ export function BacktestResultView({
|
||||
|
||||
<NotFilledCard signals={result.signal_history ?? []} />
|
||||
|
||||
<TradeReasonsCard signals={result.signal_history ?? []} factorLabels={factorLabels} />
|
||||
|
||||
<UnimplementedNote items={result.unimplemented} />
|
||||
</>
|
||||
);
|
||||
@@ -465,31 +484,10 @@ function NotFilledCard({ signals }: { signals: ActionRecord[] }) {
|
||||
}
|
||||
|
||||
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}
|
||||
series={symbolSeries(curve)}
|
||||
markers={symbolMarkers(curve)}
|
||||
height={300}
|
||||
valueFormat={(v) => `${v.toFixed(2)}%`}
|
||||
zeroLine
|
||||
@@ -498,6 +496,71 @@ function SymbolCurveChart({ curve }: { curve: SymbolCurve }) {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 因子曲线:策略里每个因子一张图(**原始值**,不做 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 }
|
||||
|
||||
Reference in New Issue
Block a user