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:
@@ -29,6 +29,20 @@ import Link from "next/link";
|
||||
import { SymbolLink, useSymbolNames } from "@/lib/symbols";
|
||||
import { adjustLabel } from "@/lib/labels";
|
||||
import type { ActionRecord, BacktestResult, SymbolCurve } from "@/lib/types";
|
||||
import { reasonLabel } from "@/lib/types";
|
||||
import { ChartPopoutLink } from "@/components/ChartPopoutLink";
|
||||
import { TradeReasonsCard, type FactorLabels } from "@/components/TradeReasons";
|
||||
import {
|
||||
drawdownSeries,
|
||||
equitySeries,
|
||||
equityValueFormat,
|
||||
factorCurveNote,
|
||||
factorSeries,
|
||||
factorValueFormat,
|
||||
portfolioMarkers,
|
||||
symbolMarkers,
|
||||
symbolSeries,
|
||||
} from "@/lib/chartSeries";
|
||||
|
||||
export interface ArchiveInfo {
|
||||
id: string;
|
||||
@@ -70,37 +84,19 @@ export function BacktestResultView({
|
||||
const nameOf = (c: SymbolCurve) => c.name ?? nameCache[c.symbol] ?? "";
|
||||
const topRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
// 组合净值上的买卖点:同一日的成交合并成一个标记,落在当日净值上
|
||||
const equitySeries = useMemo<LwSeries[]>(
|
||||
() => [
|
||||
{
|
||||
key: "equity",
|
||||
label: "组合净值(元)",
|
||||
type: "area",
|
||||
color: CHART.pos,
|
||||
data: result.equity_curve.map((p) => ({ time: p.date, value: p.value })),
|
||||
lastValueVisible: true,
|
||||
},
|
||||
],
|
||||
[result.equity_curve]
|
||||
// 曲线序列统一走 lib/chartSeries(与「新页面放大」共用同一套口径与格式)
|
||||
const equityData = useMemo<LwSeries[]>(() => equitySeries(result), [result]);
|
||||
const equityMarkers = useMemo<LwMarker[]>(
|
||||
() => portfolioMarkers(result.fills, new Set(result.equity_curve.map((p) => p.date))),
|
||||
[result]
|
||||
);
|
||||
|
||||
const equityMarkers = useMemo<LwMarker[]>(() => {
|
||||
const byDate = new Map<string, { BUY: boolean; SELL: boolean }>();
|
||||
for (const f of result.fills ?? []) {
|
||||
const cur = byDate.get(f.date) ?? { BUY: false, SELL: false };
|
||||
cur[f.signal] = true;
|
||||
byDate.set(f.date, cur);
|
||||
}
|
||||
const equity = new Set(result.equity_curve.map((p) => p.date));
|
||||
const out: LwMarker[] = [];
|
||||
for (const [d, kinds] of byDate) {
|
||||
if (!equity.has(d)) continue;
|
||||
if (kinds.BUY) out.push({ time: d, kind: "BUY", text: "买" });
|
||||
if (kinds.SELL) out.push({ time: d, kind: "SELL", text: "卖" });
|
||||
}
|
||||
// 因子键 → 展示名:买卖理由里的因子值要用中文名,不能只甩引擎键
|
||||
const factorLabels = useMemo<FactorLabels>(() => {
|
||||
const out: FactorLabels = {};
|
||||
for (const f of result.factor_curves ?? []) out[f.name] = f.label;
|
||||
return out;
|
||||
}, [result.fills, result.equity_curve]);
|
||||
}, [result.factor_curves]);
|
||||
|
||||
const filteredCurves = useMemo(() => {
|
||||
const q = curveQuery.trim().toLowerCase();
|
||||
@@ -119,10 +115,12 @@ export function BacktestResultView({
|
||||
|
||||
const SECTIONS = [
|
||||
{ id: "sec-equity", label: "整体收益" },
|
||||
{ id: "sec-factors", label: "因子曲线" },
|
||||
{ id: "sec-symbols", label: "个股曲线" },
|
||||
{ id: "sec-monthly", label: "月度/年度" },
|
||||
{ id: "sec-holdings", label: "持仓" },
|
||||
{ id: "sec-trades", label: "成交明细" },
|
||||
{ id: "sec-reasons", label: "买卖说明" },
|
||||
];
|
||||
|
||||
return (
|
||||
@@ -175,16 +173,19 @@ export function BacktestResultView({
|
||||
icon="chartLine"
|
||||
title="整体收益趋势(含买卖点)"
|
||||
tools={
|
||||
<Pill tone="pos">
|
||||
期末 {fmtNum(s.final_equity)} · 买入 {buyDays} 日 / 卖出 {sellDays} 日
|
||||
</Pill>
|
||||
<div className="row" style={{ gap: 6 }}>
|
||||
<Pill tone="pos">
|
||||
期末 {fmtNum(s.final_equity)} · 买入 {buyDays} 日 / 卖出 {sellDays} 日
|
||||
</Pill>
|
||||
<ChartPopoutLink archiveId={archive?.id} series="equity" />
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<LwChart
|
||||
series={equitySeries}
|
||||
series={equityData}
|
||||
markers={equityMarkers}
|
||||
height={320}
|
||||
valueFormat={(v) => fmtNum(v)}
|
||||
valueFormat={equityValueFormat}
|
||||
ariaLabel="组合净值曲线与买卖点"
|
||||
/>
|
||||
<div className="hint" style={{ marginTop: 6 }}>
|
||||
@@ -195,19 +196,15 @@ export function BacktestResultView({
|
||||
<Card
|
||||
icon="chartLine"
|
||||
title="回撤(%)"
|
||||
tools={<Pill tone="neg">最大 {s.max_drawdown_pct.toFixed(2)}%</Pill>}
|
||||
tools={
|
||||
<div className="row" style={{ gap: 6 }}>
|
||||
<Pill tone="neg">最大 {s.max_drawdown_pct.toFixed(2)}%</Pill>
|
||||
<ChartPopoutLink archiveId={archive?.id} series="drawdown" />
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<LwChart
|
||||
series={[
|
||||
{
|
||||
key: "dd",
|
||||
label: "回撤(%)",
|
||||
type: "area",
|
||||
color: CHART.neg,
|
||||
data: result.drawdown.map((p) => ({ time: p.date, value: p.value })),
|
||||
lastValueVisible: true,
|
||||
},
|
||||
]}
|
||||
series={drawdownSeries(result)}
|
||||
height={320}
|
||||
valueFormat={(v) => `${v.toFixed(2)}%`}
|
||||
zeroLine
|
||||
@@ -261,6 +258,11 @@ export function BacktestResultView({
|
||||
曲线口径:该股被持有期间按日复利累计(建仓当日为 0%);未持有期间不绘制,
|
||||
分段间以直线连接,请以买卖点区分持仓区间。
|
||||
</span>
|
||||
<ChartPopoutLink
|
||||
archiveId={archive?.id}
|
||||
series={`sym:${activeCurve?.symbol ?? ""}`}
|
||||
label="放大当前个股"
|
||||
/>
|
||||
</div>
|
||||
{activeCurve ? <SymbolCurveChart curve={activeCurve} /> : null}
|
||||
<div className="table-wrap" style={{ marginTop: 12 }}>
|
||||
@@ -303,7 +305,14 @@ export function BacktestResultView({
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<Card id="sec-monthly" icon="calendar" title="月度收益(%)">
|
||||
<FactorCurvesCard result={result} fills={result.fills} archiveId={archive?.id} />
|
||||
|
||||
<Card
|
||||
id="sec-monthly"
|
||||
icon="calendar"
|
||||
title="月度收益(%)"
|
||||
tools={<ChartPopoutLink archiveId={archive?.id} series="monthly" />}
|
||||
>
|
||||
<LwChart
|
||||
series={[
|
||||
{
|
||||
@@ -408,6 +417,8 @@ export function BacktestResultView({
|
||||
<th>买价</th>
|
||||
<th>卖价</th>
|
||||
<th>收益</th>
|
||||
<th>为什么买</th>
|
||||
<th>为什么卖</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -423,6 +434,12 @@ export function BacktestResultView({
|
||||
<td className={t.return_pct >= 0 ? "tone-pos" : "tone-neg"}>
|
||||
{t.return_pct.toFixed(2)}%
|
||||
</td>
|
||||
<td className="reason-brief" title={t.entry_reason?.text ?? undefined}>
|
||||
<span>{reasonLabel(t.entry_reason?.code)}</span>
|
||||
</td>
|
||||
<td className="reason-brief" title={t.exit_reason?.text ?? undefined}>
|
||||
<span>{reasonLabel(t.exit_reason?.code)}</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
@@ -433,6 +450,8 @@ export function BacktestResultView({
|
||||
|
||||
<NotFilledCard signals={result.signal_history ?? []} />
|
||||
|
||||
<TradeReasonsCard signals={result.signal_history ?? []} factorLabels={factorLabels} />
|
||||
|
||||
<UnimplementedNote items={result.unimplemented} />
|
||||
</>
|
||||
);
|
||||
@@ -465,31 +484,10 @@ function NotFilledCard({ signals }: { signals: ActionRecord[] }) {
|
||||
}
|
||||
|
||||
function SymbolCurveChart({ curve }: { curve: SymbolCurve }) {
|
||||
const valueByDate = useMemo(() => new Map(curve.points.map((p) => [p.date, p.value])), [curve]);
|
||||
const markers = useMemo<LwMarker[]>(
|
||||
() =>
|
||||
(curve.marks ?? [])
|
||||
.filter((a) => valueByDate.has(a.date))
|
||||
.map((a) => ({
|
||||
time: a.date,
|
||||
kind: a.signal,
|
||||
text: a.signal === "BUY" ? "买" : "卖",
|
||||
})),
|
||||
[curve, valueByDate]
|
||||
);
|
||||
return (
|
||||
<LwChart
|
||||
series={[
|
||||
{
|
||||
key: "sym",
|
||||
label: `${curve.symbol} 持仓期累计收益(%)`,
|
||||
type: "area",
|
||||
color: CHART.accent,
|
||||
data: curve.points.map((p) => ({ time: p.date, value: p.value })),
|
||||
lastValueVisible: true,
|
||||
},
|
||||
]}
|
||||
markers={markers}
|
||||
series={symbolSeries(curve)}
|
||||
markers={symbolMarkers(curve)}
|
||||
height={300}
|
||||
valueFormat={(v) => `${v.toFixed(2)}%`}
|
||||
zeroLine
|
||||
@@ -498,6 +496,71 @@ function SymbolCurveChart({ curve }: { curve: SymbolCurve }) {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 因子曲线:策略里每个因子一张图(**原始值**,不做 z-score)。
|
||||
*
|
||||
* 用户要求:「本因子的买卖依据是股息率,那么要增加股息率曲线」。这里把**同一批成交日**
|
||||
* 标在因子曲线上,于是能一眼看出「买在什么水平、卖在什么水平」,而不是只看净值曲线
|
||||
* 猜原因。口径(持仓加权平均、不按方向取反、空仓不落点)写在每张图下方。
|
||||
*/
|
||||
function FactorCurvesCard({
|
||||
result,
|
||||
fills,
|
||||
archiveId,
|
||||
}: {
|
||||
result: BacktestResult;
|
||||
fills?: ActionRecord[];
|
||||
archiveId?: string | null;
|
||||
}) {
|
||||
const curves = result.factor_curves ?? [];
|
||||
if (!curves.length) return null;
|
||||
return (
|
||||
<Card
|
||||
id="sec-factors"
|
||||
icon="layers"
|
||||
title={`因子曲线 · ${curves.length} 个(买卖依据的水平)`}
|
||||
tools={<Pill tone="violet">持仓加权平均原始值</Pill>}
|
||||
>
|
||||
<div className="hint" style={{ marginBottom: 10 }}>
|
||||
每个因子一条曲线:值为当日**持仓股票按市值加权平均**的因子原始值,用来回答
|
||||
「买入时这个因子处于什么水平、卖出时又变到哪」。图上 ▲/▼ 是**组合的成交日**(同一套买卖点),
|
||||
因此能直接对照「因子在什么水平触发买卖」。曲线未做 z-score、也未按方向取反;
|
||||
低为好的因子(方向标注为「越低越好」)曲线升高不等于更好。
|
||||
</div>
|
||||
<div className="chart-grid">
|
||||
{curves.map((c, i) => {
|
||||
const dates = new Set(c.points.map((p) => p.date));
|
||||
return (
|
||||
<Card
|
||||
key={c.name}
|
||||
title={c.label}
|
||||
tools={
|
||||
<div className="row" style={{ gap: 6 }}>
|
||||
<Pill tone={c.direction === "lower_is_better" ? "warn" : "pos"}>
|
||||
{c.direction === "lower_is_better" ? "越低越好" : "越高越好"}
|
||||
</Pill>
|
||||
<ChartPopoutLink archiveId={archiveId} series={`factor:${c.name}`} />
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<LwChart
|
||||
series={[factorSeries(c, i)]}
|
||||
markers={portfolioMarkers(fills, dates)}
|
||||
height={280}
|
||||
valueFormat={factorValueFormat(c)}
|
||||
ariaLabel={`因子 ${c.label} 的持仓加权曲线与买卖点`}
|
||||
/>
|
||||
<div className="hint" style={{ marginTop: 6 }}>
|
||||
{factorCurveNote(c)}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function poolLabel(result: BacktestResult): string {
|
||||
const sel = result.config_snapshot?.selection as
|
||||
| { top_n?: number; hold_top_x?: number | null }
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* 「新页面放大」入口:把某条曲线在 `/charts/{归档id}?s={曲线}` 里整页打开。
|
||||
*
|
||||
* 为什么要走归档而不是把数据塞进新标签页:新标签页与原页不共享内存/存储,
|
||||
* 唯一可靠的传递方式就是 URL;而回测结果本来就**已经归档**(`experiment_id`),
|
||||
* 让放大页从归档读同一份数据,还能顺带保证「看到的图与归档一致」。
|
||||
*
|
||||
* 没有归档 id 时(同步接口没归档、或归档已被删除)**不给假按钮**:直接说明原因。
|
||||
*/
|
||||
|
||||
import Link from "next/link";
|
||||
|
||||
import { Icon } from "@/components/icons";
|
||||
|
||||
export function ChartPopoutLink({
|
||||
archiveId,
|
||||
series,
|
||||
label = "新页面放大",
|
||||
size = "sm",
|
||||
}: {
|
||||
archiveId?: string | null;
|
||||
series: string;
|
||||
label?: string;
|
||||
size?: "sm" | "md";
|
||||
}) {
|
||||
if (!archiveId) {
|
||||
return (
|
||||
<span
|
||||
className="hint"
|
||||
title="本次结果没有归档 id(未归档或归档已删除),无法在新页面打开同一份数据"
|
||||
>
|
||||
未归档,无法放大
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Link
|
||||
className={size === "sm" ? "btn btn--sm" : "btn"}
|
||||
href={`/charts/${encodeURIComponent(archiveId)}?s=${encodeURIComponent(series)}`}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
title="在新标签页整页打开这条曲线"
|
||||
>
|
||||
<Icon name="arrowUpRight" size={13} />
|
||||
<span>{label}</span>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -62,8 +62,17 @@ export interface LwChartProps {
|
||||
series: LwSeries[];
|
||||
markers?: LwMarker[];
|
||||
height?: number;
|
||||
/** 悬浮提示与价格轴的数值格式 */
|
||||
/** 悬浮提示与价格轴的数值格式(客户端组件用;Server Component 请用 formatKey) */
|
||||
valueFormat?: (v: number) => string;
|
||||
/**
|
||||
* 数值格式的**可序列化标识**。
|
||||
*
|
||||
* 为什么需要它:Server Component 不能把函数传给 Client Component(`valueFormat`
|
||||
* 会直接 500:「Functions cannot be passed directly to Client Components」)。
|
||||
* 因此服务端渲染的图表(归档详情、曲线放大页)用这个标识指定格式,由本组件在
|
||||
* 客户端解析成函数 —— 两端看到的数字格式仍然只有一份定义。
|
||||
*/
|
||||
formatKey?: LwFormatKey;
|
||||
/** 画一条 0 基准虚线(收益率曲线推荐开启) */
|
||||
zeroLine?: boolean;
|
||||
legend?: boolean;
|
||||
@@ -73,6 +82,27 @@ export interface LwChartProps {
|
||||
|
||||
type AnySeries = ISeriesApi<"Line"> | ISeriesApi<"Area"> | ISeriesApi<"Histogram">;
|
||||
|
||||
/** 数值格式的可序列化标识(Server Component ↔ Client Component 的桥) */
|
||||
export type LwFormatKey =
|
||||
| "num"
|
||||
| "num0"
|
||||
| "pct2"
|
||||
| "pct3"
|
||||
| "times3"
|
||||
| "auto4"
|
||||
/** 原值为小数、按百分数显示(0.15 → 15.00%) */
|
||||
| "frac-pct";
|
||||
|
||||
export const FORMATTERS: Record<LwFormatKey, (v: number) => string> = {
|
||||
num: (v) => v.toLocaleString("zh-CN", { maximumFractionDigits: 2 }),
|
||||
num0: (v) => v.toLocaleString("zh-CN", { maximumFractionDigits: 0 }),
|
||||
pct2: (v) => `${v.toFixed(2)}%`,
|
||||
pct3: (v) => `${v.toFixed(3)}%`,
|
||||
times3: (v) => `${v.toFixed(3)}×`,
|
||||
auto4: (v) => v.toFixed(4),
|
||||
"frac-pct": (v) => `${(v * 100).toFixed(2)}%`,
|
||||
};
|
||||
|
||||
function toTime(t: string): Time {
|
||||
return t as Time;
|
||||
}
|
||||
@@ -102,6 +132,7 @@ export function LwChart({
|
||||
markers = [],
|
||||
height = 320,
|
||||
valueFormat,
|
||||
formatKey,
|
||||
zeroLine = false,
|
||||
legend = true,
|
||||
ariaLabel,
|
||||
@@ -114,8 +145,9 @@ export function LwChart({
|
||||
const zeroLineDrawn = useRef(false);
|
||||
/** 图例隐藏集合:tooltip 订阅里读取,用 ref 避免闭包过期 */
|
||||
const hiddenRef = useRef<Set<string>>(new Set());
|
||||
const fmtRef = useRef(valueFormat);
|
||||
fmtRef.current = valueFormat;
|
||||
const resolvedFormat = valueFormat ?? (formatKey ? FORMATTERS[formatKey] : undefined);
|
||||
const fmtRef = useRef(resolvedFormat);
|
||||
fmtRef.current = resolvedFormat;
|
||||
|
||||
const [hidden, setHidden] = useState<Set<string>>(new Set());
|
||||
const [tip, setTip] = useState<{
|
||||
|
||||
Reference in New Issue
Block a user