"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; 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 ( {fallback ? `${fallback}(旧归档无结构化理由)` : "—(旧归档无结构化理由)"} ); } const facts = reasonFacts(reason, factorLabels); return (
{reasonLabel(reason.code)} {reason.text}
{facts.length ? (
{facts.map((f) => ( {f} ))}
) : null}
); } type Side = "all" | "BUY" | "SELL"; type Fill = "all" | "filled" | "unfilled"; /** * 全部买卖点 + 理由(默认按日期倒序,最新的一笔在最上面)。 * * 为什么默认倒序:回测结果里「最近发生了什么」通常是最想看的;要按时间顺序读, * 点表头「日期」即可切换。 */ export function TradeReasonsCard({ signals, factorLabels = {}, }: { signals: ActionRecord[]; factorLabels?: FactorLabels; }) { const [side, setSide] = useState("all"); const [fill, setFill] = useState("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 ( 已成交 {filled} 未成交 {signals.length - filled} {withReason}/{signals.length} 条带结构化理由 } >
{( [ ["all", "全部"], ["BUY", "买入"], ["SELL", "卖出"], ] as [Side, string][] ).map(([v, label]) => ( ))}
{( [ ["all", "不限成交"], ["filled", "只看成交"], ["unfilled", "只看未成交"], ] as [Fill, string][] ).map(([v, label]) => ( ))}
setQ(e.target.value)} aria-label="搜索买卖点" /> {rows.length} 条
{rows.map((a) => ( ))}
方向 股票 价格 买卖理由(引擎给出的当时数字)
{a.date} {a.filled ? ( {a.signal === "BUY" ? "买入" : "卖出"} ) : ( {a.signal === "BUY" ? "想买未成" : "想卖未成"} )} {a.price != null ? fmtFixed(a.price, 2) : "—"}
「想买未成 / 想卖未成」= 策略当天确实要下单,但被涨停、跌停、停牌或现金挡住 (执行层原因在理由里写清)。名次、综合分、因子值、持有交易日都取自**引擎当时的计算**, 界面不做二次推算;因子值是原始值(未做 z-score、不按方向取反)。 {withReason < signals.length ? ` 有 ${signals.length - withReason} 条来自 2026-10 之前的旧归档,当时还没有结构化理由。` : ""}
); }