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:
Simon
2026-10-01 17:57:00 +08:00
parent 36fe018075
commit 48a97c2a12
17 changed files with 2103 additions and 158 deletions
+308
View File
@@ -0,0 +1,308 @@
"use client";
/**
* 买卖理由表(回测结果的「所有买卖点为什么买 / 为什么卖」)。
*
* 用户要求:「在所有买卖点详细说明买卖理由。用数据说话。」因此这里的原则是:
* 1. **一个点都不省**:成交的、没成交的(涨停/停牌/现金不足)、Tmin 保护暂留的,
* 全部来自 `signal_history`,不漏;
* 2. **数字只来自引擎**:`reason.data` 里的名次 / 综合分 / 因子原始值 / 持有交易日
* 直接展示,前端不做任何推算(免得出现「看起来像真的」的数字);
* 3. **可核对**:原因分类(code)给中文短标签,点击筛选;理由原文可读。
*
* 老归档(2026-10 之前)没有 `reason` 字段,退化为展示原有的 `reject_reason`,
* 并明确标注「旧归档无结构化理由」——不假装有数据。
*/
import { useMemo, useState } from "react";
import type { ActionRecord, TradeReason } from "@/lib/types";
import { reasonLabel } from "@/lib/types";
import { Card, Pill } from "@/components/ui";
import { SymbolLink } from "@/lib/symbols";
/** 因子键 → 展示名(来自结果里的 factor_curves;取不到就用引擎键) */
export type FactorLabels = Record<string, string>;
function fmtNum(v: number, digits = 2): string {
return v.toLocaleString("zh-CN", { maximumFractionDigits: digits });
}
/**
* 定点格式化,**与后端 `f"{v:.4f}"` 的舍入规则一致**(四舍六入五成双)。
*
* 为什么不能用 `toFixed`:引擎理由原文里 0.03125 写成 `0.0312`(Python 是 bankers'
* rounding),而 JS `toFixed` 是「五入」→ `0.0313`。同一个综合分在「理由原文」和旁边的
* 数字标签里显示成两个数,用户会合理地怀疑数据不一致 —— 这类不一致必须消掉。
*
* 实现:先展开成**足够长的十进制**(40 位小数,覆盖 double 的有效位,避免「先按
* digits+2 位舍入」制造出假的中点),再对这个十进制字符串做「五成双」舍入。
*/
function fmtFixed(v: number, digits: number): string {
if (!Number.isFinite(v)) return String(v);
const neg = v < 0;
const s = Math.abs(v).toFixed(40);
const [intPart, fracPart = ""] = s.split(".");
const keep = fracPart.slice(0, digits).padEnd(digits, "0");
const rest = fracPart.slice(digits);
const firstDropped = rest.length ? rest.charCodeAt(0) - 48 : 0;
const laterNonZero = /[1-9]/.test(rest.slice(1));
const digitsArr = (intPart + keep).split("");
const lastDigit = Number(digitsArr[digitsArr.length - 1]);
if (firstDropped > 5 || (firstDropped === 5 && (laterNonZero || lastDigit % 2 === 1))) {
let i = digitsArr.length - 1;
for (; i >= 0; i -= 1) {
const d = Number(digitsArr[i]) + 1;
if (d < 10) {
digitsArr[i] = String(d);
break;
}
digitsArr[i] = "0";
}
if (i < 0) digitsArr.unshift("1");
}
const all = digitsArr.join("");
const intOut = all.slice(0, all.length - digits) || "0";
const fracOut = digits ? all.slice(all.length - digits) : "";
return `${neg ? "-" : ""}${intOut}${digits ? `.${fracOut}` : ""}`;
}
/** 结构化理由里「用数据说话」的那几个数字(有才显示,没有不编) */
function reasonFacts(reason: TradeReason, factorLabels: FactorLabels): string[] {
const d = reason.data ?? {};
const out: string[] = [];
if (typeof d.rank === "number") {
out.push(
`综合分第 ${d.rank}${typeof d.total === "number" ? `/${d.total}` : ""} 名` +
(typeof d.top_n === "number" ? `(TopN=${d.top_n})` : "")
);
} else if (typeof d.total === "number") {
out.push(`候选 ${d.total} 只(当日无该股分数)`);
}
if (typeof d.score === "number") out.push(`综合分 ${fmtFixed(d.score, 4)}`);
if (typeof d.hold_days === "number") {
out.push(
`持有 ${d.hold_days} 个交易日` +
(typeof d.tmin === "number" ? `(Tmin=${d.tmin})` : "") +
(typeof d.tmax === "number" ? `(Tmax=${d.tmax})` : "")
);
}
if (typeof d.close_prev_ratio === "number") {
out.push(
`收盘/前收 = ${fmtFixed(d.close_prev_ratio, 3)}` +
(typeof d.limit_ratio === "number" ? `(阈值 ${fmtFixed(d.limit_ratio, 3)})` : "")
);
}
if (typeof d.budget === "number") out.push(`可用预算 ${fmtNum(d.budget)} 元`);
if (typeof d.min_commission === "number") out.push(`最低佣金 ${fmtNum(d.min_commission)} 元`);
if (d.in_pool === false) out.push("已不在候选池(被股票池/条件过滤)");
if (typeof d.return_pct === "number") out.push(`本笔收益 ${fmtFixed(d.return_pct, 2)}%`);
const factors = d.factors ?? {};
for (const [key, value] of Object.entries(factors)) {
if (typeof value !== "number") continue;
out.push(`${factorLabels[key] ?? key} = ${fmtFixed(value, 4)}`);
}
return out;
}
/** 单条理由:分类标签 + 理由原文 + 关键数字 */
export function TradeReasonCell({
reason,
fallback,
factorLabels = {},
}: {
reason?: TradeReason | null;
fallback?: string | null;
factorLabels?: FactorLabels;
}) {
if (!reason) {
// 老归档没有结构化理由:如实标注,并退回执行层文案(不假装有数据)
return (
<span className="hint">
{fallback ? `${fallback}(旧归档无结构化理由)` : "—(旧归档无结构化理由)"}
</span>
);
}
const facts = reasonFacts(reason, factorLabels);
return (
<div className="reason-cell">
<div className="reason-cell__head">
<Pill tone={reason.code.startsWith("buy") ? "pos" : "warn"}>{reasonLabel(reason.code)}</Pill>
<span className="reason-cell__text">{reason.text}</span>
</div>
{facts.length ? (
<div className="reason-cell__facts">
{facts.map((f) => (
<span className="chip" key={f}>
{f}
</span>
))}
</div>
) : null}
</div>
);
}
type Side = "all" | "BUY" | "SELL";
type Fill = "all" | "filled" | "unfilled";
/**
* 全部买卖点 + 理由(默认按日期倒序,最新的一笔在最上面)。
*
* 为什么默认倒序:回测结果里「最近发生了什么」通常是最想看的;要按时间顺序读,
* 点表头「日期」即可切换。
*/
export function TradeReasonsCard({
signals,
factorLabels = {},
}: {
signals: ActionRecord[];
factorLabels?: FactorLabels;
}) {
const [side, setSide] = useState<Side>("all");
const [fill, setFill] = useState<Fill>("all");
const [q, setQ] = useState("");
const [desc, setDesc] = useState(true);
const rows = useMemo(() => {
const needle = q.trim().toLowerCase();
const out = signals.filter((a) => {
if (side !== "all" && a.signal !== side) return false;
if (fill === "filled" && !a.filled) return false;
if (fill === "unfilled" && a.filled) return false;
if (!needle) return true;
return (
a.symbol.toLowerCase().includes(needle) ||
(a.name ?? "").toLowerCase().includes(needle) ||
(a.reason?.text ?? "").toLowerCase().includes(needle)
);
});
out.sort((a, b) => (a.date === b.date ? a.symbol.localeCompare(b.symbol) : a.date < b.date ? -1 : 1));
return desc ? out.reverse() : out;
}, [signals, side, fill, q, desc]);
const filled = signals.filter((a) => a.filled).length;
const withReason = signals.filter((a) => a.reason).length;
if (!signals.length) return null;
return (
<Card
id="sec-reasons"
icon="book"
title={`买卖说明 · ${signals.length} 个买卖点`}
tools={
<div className="row" style={{ gap: 6, flexWrap: "wrap" }}>
<Pill>已成交 {filled}</Pill>
<Pill tone="warn">未成交 {signals.length - filled}</Pill>
<Pill tone={withReason === signals.length ? "pos" : "warn"}>
{withReason}/{signals.length} 条带结构化理由
</Pill>
</div>
}
>
<div className="row" style={{ gap: 8, flexWrap: "wrap", marginBottom: 10 }}>
<div className="seg">
{(
[
["all", "全部"],
["BUY", "买入"],
["SELL", "卖出"],
] as [Side, string][]
).map(([v, label]) => (
<button
key={v}
type="button"
className={side === v ? "seg__btn is-on" : "seg__btn"}
onClick={() => setSide(v)}
>
{label}
</button>
))}
</div>
<div className="seg">
{(
[
["all", "不限成交"],
["filled", "只看成交"],
["unfilled", "只看未成交"],
] as [Fill, string][]
).map(([v, label]) => (
<button
key={v}
type="button"
className={fill === v ? "seg__btn is-on" : "seg__btn"}
onClick={() => setFill(v)}
>
{label}
</button>
))}
</div>
<input
className="input input--search"
placeholder="搜索代码 / 名称 / 理由"
value={q}
onChange={(e) => setQ(e.target.value)}
aria-label="搜索买卖点"
/>
<Pill>{rows.length} 条</Pill>
</div>
<div className="table-wrap">
<table className="tbl">
<thead>
<tr>
<th>
<button type="button" className="th-sort" onClick={() => setDesc((d) => !d)}>
日期 {desc ? "↓" : "↑"}
</button>
</th>
<th>方向</th>
<th>股票</th>
<th>价格</th>
<th>买卖理由(引擎给出的当时数字)</th>
</tr>
</thead>
<tbody>
{rows.map((a) => (
<tr key={`${a.signal}-${a.date}-${a.symbol}-${a.price ?? ""}`}>
<td className="mono dim nowrap">{a.date}</td>
<td>
{a.filled ? (
<Pill tone={a.signal === "BUY" ? "pos" : "neg"}>
{a.signal === "BUY" ? "买入" : "卖出"}
</Pill>
) : (
<Pill tone="warn" icon="alert">
{a.signal === "BUY" ? "想买未成" : "想卖未成"}
</Pill>
)}
</td>
<td className="sym-cell">
<SymbolLink symbol={a.symbol} name={a.name} />
</td>
<td className="mono nowrap">{a.price != null ? fmtFixed(a.price, 2) : "—"}</td>
<td>
<TradeReasonCell
reason={a.reason}
fallback={a.reject_reason}
factorLabels={factorLabels}
/>
</td>
</tr>
))}
</tbody>
</table>
</div>
<div className="hint" style={{ marginTop: 8 }}>
「想买未成 / 想卖未成」= 策略当天确实要下单,但被涨停、跌停、停牌或现金挡住
(执行层原因在理由里写清)。名次、综合分、因子值、持有交易日都取自**引擎当时的计算**,
界面不做二次推算;因子值是原始值(未做 z-score、不按方向取反)。
{withReason < signals.length
? ` 有 ${signals.length - withReason} 条来自 2026-10 之前的旧归档,当时还没有结构化理由。`
: ""}
</div>
</Card>
);
}