Files
qlib/frontend/web/app/charts/[id]/page.tsx
T
Simon 48a97c2a12 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 渲染,截图确认表格与曲线数值正确。
2026-10-01 17:57:00 +08:00

237 lines
9.2 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.
/**
* 曲线放大页(`/charts/{归档id}?s={曲线}`)—— 「所有曲线都能弹出新页面看大的」。
*
* 设计取舍:
* - **Server Component**:数据从归档直出(URL 即快照地址,刷新/分享都还原同一张图);
* 曲线切换用 URL 参数(`?s=`),每个切换按钮就是一个 `<Link>`,不需要客户端状态。
* - **一页一曲线、尽可能大**:放大页只干一件事 —— 把一条曲线画大。因此高度直接给足
* (`CHART_HEIGHT`),并给出该曲线的口径说明与买卖点标注。
* - **数据只来自归档**:不重新跑回测、不从内存里取,避免「放大页与归档不一致」。
* - 归档不存在 → 404;归档类型没有该曲线 → 如实说明并给回归档详情页的入口。
*/
import { notFound } from "next/navigation";
import Link from "next/link";
import { LwChart } from "@/components/charts/LwChart";
import { CHART, fmtNum } from "@/components/charts/theme";
import { Card, Pill, Banner } from "@/components/ui";
import type { LwFormatKey } from "@/components/charts/LwChart";
import {
drawdownSeries,
equitySeries,
factorCurveNote,
factorFormatKey,
factorSeries,
monthlySeries,
portfolioMarkers,
symbolMarkers,
symbolSeries,
} from "@/lib/chartSeries";
import { experimentKindLabel } from "@/lib/labels";
import type { BacktestResult, ExperimentDetail } from "@/lib/types";
export const dynamic = "force-dynamic";
const BACKEND = (process.env.BACKEND_API_URL ?? "http://127.0.0.1:8000").replace(/\/$/, "");
/** 放大页的主要目的就是「看大图」,给足高度(窄屏靠 CSS 缩到视口内) */
const CHART_HEIGHT = 640;
async function serverGet<T>(path: string): Promise<T | null> {
try {
const r = await fetch(`${BACKEND}/api${path}`, { cache: "no-store" });
if (!r.ok) return null;
return (await r.json()) as T;
} catch {
return null;
}
}
interface Option {
key: string;
label: string;
hint: string;
}
/** 该归档里所有可放大的曲线(顺序即推荐阅读顺序) */
function optionsFor(result: BacktestResult): Option[] {
const out: Option[] = [
{ key: "equity", label: "组合净值", hint: "含买卖点(成交日)" },
{ key: "drawdown", label: "回撤", hint: "距历史最高的回撤(%)" },
];
for (const f of result.factor_curves ?? []) {
out.push({ key: `factor:${f.name}`, label: `因子 · ${f.label}`, hint: "持仓加权平均原始值" });
}
for (const c of result.symbol_curves ?? []) {
out.push({ key: `sym:${c.symbol}`, label: `个股 · ${c.symbol}`, hint: "持仓期累计收益(%)" });
}
if ((result.monthly_returns ?? []).length) {
out.push({ key: "monthly", label: "月度收益", hint: "每月收益(%)" });
}
return out;
}
export default async function ChartPage({
params,
searchParams,
}: {
params: Promise<{ id: string }>;
searchParams: Promise<{ s?: string }>;
}) {
const { id: rawId } = await params;
const id = decodeURIComponent(rawId ?? "");
const sp = await searchParams;
const detail = await serverGet<ExperimentDetail>(`/experiments/${encodeURIComponent(id)}`);
if (!detail) notFound();
const result = detail.result as BacktestResult | null;
const isBacktest = Boolean(result && Array.isArray(result.equity_curve));
if (!isBacktest) {
return (
<Card icon="chartLine" title="该归档没有可放大的回测曲线">
<Banner tone="info">
归档 {id} 的类型是「{experimentKindLabel(detail.kind)}」,它的结果结构里没有净值 /
因子曲线(这些曲线只在回测归档里)。这不是错误,只是曲线放大页不适用于该类型。
</Banner>
<div style={{ marginTop: 10 }}>
<Link className="btn btn--sm" href={`/experiments/${encodeURIComponent(id)}`}>
<span>打开归档详情</span>
</Link>
</div>
</Card>
);
}
const res = result as BacktestResult;
const options = optionsFor(res);
const want = sp?.s ?? options[0]?.key ?? "equity";
const current = options.find((o) => o.key === want) ?? options[0];
const factorKey = current?.key.startsWith("factor:") ? current.key.slice("factor:".length) : null;
const symKey = current?.key.startsWith("sym:") ? current.key.slice("sym:".length) : null;
let series = equitySeries(res);
let markers = portfolioMarkers(
res.fills,
new Set(res.equity_curve.map((p) => p.date))
);
let formatKey: LwFormatKey = "num";
let note = "组合净值(元):每日收盘后按持仓市值结算;▲ 绿 = 当日有买入成交,▼ 红 = 当日有卖出成交。";
let zeroLine = false;
if (factorKey) {
const curve = (res.factor_curves ?? []).find((f) => f.name === factorKey);
if (curve) {
const idx = (res.factor_curves ?? []).findIndex((f) => f.name === factorKey);
series = [factorSeries(curve, idx)];
formatKey = factorFormatKey(curve);
note = factorCurveNote(curve);
// 因子曲线上的买卖点:把组合的成交日标在因子曲线上,直接看「买卖发生在什么水平」
markers = portfolioMarkers(res.fills, new Set(curve.points.map((p) => p.date)));
}
} else if (symKey) {
const curve = (res.symbol_curves ?? []).find((c) => c.symbol === symKey);
if (curve) {
series = symbolSeries(curve);
markers = symbolMarkers(curve);
formatKey = "pct2";
note =
`${curve.symbol} 持仓期间的累计收益率(%,以建仓日收盘为 0% 基准,按日复利),` +
"只在该股持仓的交易日落点;买卖点为实际成交。";
zeroLine = true;
}
} else if (current?.key === "drawdown") {
series = drawdownSeries(res);
markers = [];
formatKey = "pct2";
note = "回撤(%):净值相对历史最高点的跌幅,越负越深。";
zeroLine = true;
} else if (current?.key === "monthly") {
series = monthlySeries(res);
markers = [];
formatKey = "pct2";
note = "月度收益(%):每个月末相对上月末的净值变化。";
zeroLine = true;
}
const seriesLabel = series[0]?.label ?? current?.label ?? "曲线";
return (
<>
<div className="between" style={{ margin: "4px 0 12px", gap: 12, flexWrap: "wrap" }}>
<div className="row" style={{ gap: 8, flexWrap: "wrap" }}>
<b style={{ fontSize: 16 }}>曲线放大</b>
<Pill tone="accent">{experimentKindLabel(detail.kind)}</Pill>
<span className="mono hint">{id}</span>
{detail.created_at ? (
<span className="hint">归档于 {String(detail.created_at).slice(0, 19).replace("T", " ")}</span>
) : null}
</div>
<div className="row" style={{ gap: 8 }}>
<Link className="btn btn--sm" href={`/experiments/${encodeURIComponent(id)}`}>
<span>打开归档详情</span>
</Link>
</div>
</div>
<div className="row" style={{ gap: 6, flexWrap: "wrap", marginBottom: 10 }}>
{options.map((o) => (
<Link
key={o.key}
className={o.key === current?.key ? "btn btn--sm btn--primary" : "btn btn--sm"}
href={`/charts/${encodeURIComponent(id)}?s=${encodeURIComponent(o.key)}`}
title={o.hint}
>
<span>{o.label}</span>
</Link>
))}
</div>
<Card
icon="chartLine"
title={seriesLabel}
tools={
<Pill tone={series[0]?.type === "bar" ? "violet" : "pos"}>
{series[0]?.data.length ?? 0} 个点
{markers.length ? ` · ${markers.length} 个买卖标注` : ""}
</Pill>
}
>
<LwChart
series={series}
markers={markers}
height={CHART_HEIGHT}
formatKey={formatKey}
zeroLine={zeroLine}
ariaLabel={`${seriesLabel}(放大)`}
/>
<div className="hint" style={{ marginTop: 8 }}>
{note} 拖动 / 滚轮可缩放,双击图例可临时隐藏曲线;地址栏 URL 可直接分享或收藏
(换设备打开还原同一张图,因为数据来自归档快照)。
</div>
</Card>
{res.summary ? (
<Card icon="gauge" title="这次回测的关键指标(与曲线同一份快照)">
<div className="row" style={{ gap: 16, flexWrap: "wrap" }}>
<span>
区间 <span className="mono">{res.summary.start}</span> ~{" "}
<span className="mono">{res.summary.end}</span>
</span>
<span>
总收益{" "}
<b className={res.summary.total_return_pct >= 0 ? "tone-pos" : "tone-neg"}>
{res.summary.total_return_pct.toFixed(2)}%
</b>
</span>
<span>年化 {res.summary.annual_return_pct.toFixed(2)}%</span>
<span>Sharpe {res.summary.sharpe.toFixed(3)}</span>
<span>最大回撤 {res.summary.max_drawdown_pct.toFixed(2)}%</span>
<span>期末权益 {fmtNum(res.summary.final_equity)}</span>
<span className="hint" style={{ color: CHART.faint }}>
共 {res.summary.total_trades} 笔成交
</span>
</div>
</Card>
) : null}
</>
);
}