用户要求:「所有买卖点详细说明买卖理由,用数据说话」「回测图上增加因子相关曲线
(买卖依据是股息率,就加股息率曲线)」「所有曲线能弹出新页面放大」。
一、买卖理由(后端产出结构化数据,前端只展示)
- 新增 `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 渲染,截图确认表格与曲线数值正确。
308 lines
11 KiB
TypeScript
308 lines
11 KiB
TypeScript
"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>
|
||
);
|
||
} |