Files
qlib/frontend/web/components/TradeReasons.tsx
T
Simon 57d6082f91 fix: 如实说明买卖理由的覆盖边界(哪些不算买卖点)
`unimplemented` 与「买卖说明」里都没写清一件事:理由覆盖的是 **signal_history 里的
每个买卖点(含涨停/停牌/跌停/现金不足等未成交情形)**,而「当日排名在 TopN 之外、
策略本来就无意买入」的候选根本不算买卖点 —— 用户看不到某只票的买入理由时,
应该能立刻分清「是漏了记录」还是「策略本来就没打算买」。

- 组合引擎与单策略引擎的 `_unimplemented` 各加一条说明,指向 `selection_history`
  可查完整候选与名次(AGENT.md §24:没实现/有边界的要显式写出)。
- 「买卖说明」卡片底部同步写出这条边界。
2026-10-01 18:15:26 +08:00

311 lines
12 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.
"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 之前的旧归档,当时还没有结构化理由。`
: ""}
{" "}覆盖边界:「当日排名在 TopN 之外、策略本来就无意买入」的候选不算买卖点
(要看完整候选与名次,请看选股明细);每个真正的买卖点 —— 包括涨停、停牌、
跌停、现金不足这类没成交的 —— 都在上表里。
</div>
</Card>
);
}