`unimplemented` 与「买卖说明」里都没写清一件事:理由覆盖的是 **signal_history 里的 每个买卖点(含涨停/停牌/跌停/现金不足等未成交情形)**,而「当日排名在 TopN 之外、 策略本来就无意买入」的候选根本不算买卖点 —— 用户看不到某只票的买入理由时, 应该能立刻分清「是漏了记录」还是「策略本来就没打算买」。 - 组合引擎与单策略引擎的 `_unimplemented` 各加一条说明,指向 `selection_history` 可查完整候选与名次(AGENT.md §24:没实现/有边界的要显式写出)。 - 「买卖说明」卡片底部同步写出这条边界。
311 lines
12 KiB
TypeScript
311 lines
12 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 之前的旧归档,当时还没有结构化理由。`
|
||
: ""}
|
||
{" "}覆盖边界:「当日排名在 TopN 之外、策略本来就无意买入」的候选不算买卖点
|
||
(要看完整候选与名次,请看选股明细);每个真正的买卖点 —— 包括涨停、停牌、
|
||
跌停、现金不足这类没成交的 —— 都在上表里。
|
||
</div>
|
||
</Card>
|
||
);
|
||
} |