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:
Simon
2026-10-01 17:57:00 +08:00
parent 36fe018075
commit 48a97c2a12
17 changed files with 2103 additions and 158 deletions
+12 -1
View File
@@ -127,6 +127,9 @@ function BacktestInner() {
const [draft, setDraft] = useState<Draft>(emptyDraft(range));
const [savedComboId, setSavedComboId] = useState<string | null>(null);
const [result, setResult] = useState<BacktestResult | null>(null);
// 本次结果的归档 id:结果区据此提供「新页面放大」(放大页从归档读同一份数据)。
// 同步/未归档的结果没有 id,放大入口会如实说明原因而不是给个坏链接。
const [resultArchiveId, setResultArchiveId] = useState<string | null>(null);
const [running, setRunning] = useState(false);
const [runningComboId, setRunningComboId] = useState<string | null>(null);
const [jobId, setJobId] = useState("");
@@ -239,6 +242,7 @@ function BacktestInner() {
setNotice("");
setJobId("");
setResult(null);
setResultArchiveId(null);
try {
const { job_id } = await submit();
setJobId(job_id);
@@ -250,6 +254,7 @@ function BacktestInner() {
if (out.status === "success" && out.result) {
setResult(out.result);
const exp = out.experimentId ?? null;
setResultArchiveId(exp);
setNotice(exp ? `回测完成,已归档为实验 ${exp}(可在「实验对比」页与其它版本对比)。` : "回测完成,已自动归档。");
} else {
setError(`任务${out.status}${out.error ? `:${out.error}` : ""}`);
@@ -649,7 +654,13 @@ function BacktestInner() {
</Card>
) : null}
{result ? <BacktestResultView result={result} name={draft.name || "回测组合"} /> : null}
{result ? (
<BacktestResultView
result={result}
name={draft.name || "回测组合"}
archive={resultArchiveId ? { id: resultArchiveId } : undefined}
/>
) : null}
</>
);
}
+237
View File
@@ -0,0 +1,237 @@
/**
* 曲线放大页(`/charts/{归档id}?s={曲线}`)—— 「所有曲线都能弹出新页面看大的」。
*
* 设计取舍:
* - **Server Component**:数据从归档直出(URL 即快照地址,刷新/分享都还原同一张图);
* 曲线切换用 URL 参数(`?s=`),每个切换按钮就是一个 `<Link>`,不需要客户端状态。
* - **一页一曲线、尽可能大**:放大页只干一件事 —— 把一条曲线画大。因此高度直接给足
* (`CHART_HEIGHT`),并给出该曲线的口径说明与买卖点标注。
* - **数据只来自归档**:不重新跑回测、不从内存里取,避免「放大页与归档不一致」。
* - 归档不存在 → 404;归档类型没有该曲线 → 如实说明并给回归档详情页的入口。
*/
import { notFound } from "next/navigation";
import Link from "next/link";
import { LwChart } from "@/components/charts/LwChart";
import { CHART, fmtNum } from "@/components/charts/theme";
import { Card, Pill, Banner } from "@/components/ui";
import type { LwFormatKey } from "@/components/charts/LwChart";
import {
drawdownSeries,
equitySeries,
factorCurveNote,
factorFormatKey,
factorSeries,
monthlySeries,
portfolioMarkers,
symbolMarkers,
symbolSeries,
} from "@/lib/chartSeries";
import { experimentKindLabel } from "@/lib/labels";
import type { BacktestResult, ExperimentDetail } from "@/lib/types";
export const dynamic = "force-dynamic";
const BACKEND = (process.env.BACKEND_API_URL ?? "http://127.0.0.1:8000").replace(/\/$/, "");
/** 放大页的主要目的就是「看大图」,给足高度(窄屏靠 CSS 缩到视口内) */
const CHART_HEIGHT = 640;
async function serverGet<T>(path: string): Promise<T | null> {
try {
const r = await fetch(`${BACKEND}/api${path}`, { cache: "no-store" });
if (!r.ok) return null;
return (await r.json()) as T;
} catch {
return null;
}
}
interface Option {
key: string;
label: string;
hint: string;
}
/** 该归档里所有可放大的曲线(顺序即推荐阅读顺序) */
function optionsFor(result: BacktestResult): Option[] {
const out: Option[] = [
{ key: "equity", label: "组合净值", hint: "含买卖点(成交日)" },
{ key: "drawdown", label: "回撤", hint: "距历史最高的回撤(%)" },
];
for (const f of result.factor_curves ?? []) {
out.push({ key: `factor:${f.name}`, label: `因子 · ${f.label}`, hint: "持仓加权平均原始值" });
}
for (const c of result.symbol_curves ?? []) {
out.push({ key: `sym:${c.symbol}`, label: `个股 · ${c.symbol}`, hint: "持仓期累计收益(%)" });
}
if ((result.monthly_returns ?? []).length) {
out.push({ key: "monthly", label: "月度收益", hint: "每月收益(%)" });
}
return out;
}
export default async function ChartPage({
params,
searchParams,
}: {
params: Promise<{ id: string }>;
searchParams: Promise<{ s?: string }>;
}) {
const { id: rawId } = await params;
const id = decodeURIComponent(rawId ?? "");
const sp = await searchParams;
const detail = await serverGet<ExperimentDetail>(`/experiments/${encodeURIComponent(id)}`);
if (!detail) notFound();
const result = detail.result as BacktestResult | null;
const isBacktest = Boolean(result && Array.isArray(result.equity_curve));
if (!isBacktest) {
return (
<Card icon="chartLine" title="该归档没有可放大的回测曲线">
<Banner tone="info">
归档 {id} 的类型是「{experimentKindLabel(detail.kind)}」,它的结果结构里没有净值 /
因子曲线(这些曲线只在回测归档里)。这不是错误,只是曲线放大页不适用于该类型。
</Banner>
<div style={{ marginTop: 10 }}>
<Link className="btn btn--sm" href={`/experiments/${encodeURIComponent(id)}`}>
<span>打开归档详情</span>
</Link>
</div>
</Card>
);
}
const res = result as BacktestResult;
const options = optionsFor(res);
const want = sp?.s ?? options[0]?.key ?? "equity";
const current = options.find((o) => o.key === want) ?? options[0];
const factorKey = current?.key.startsWith("factor:") ? current.key.slice("factor:".length) : null;
const symKey = current?.key.startsWith("sym:") ? current.key.slice("sym:".length) : null;
let series = equitySeries(res);
let markers = portfolioMarkers(
res.fills,
new Set(res.equity_curve.map((p) => p.date))
);
let formatKey: LwFormatKey = "num";
let note = "组合净值(元):每日收盘后按持仓市值结算;▲ 绿 = 当日有买入成交,▼ 红 = 当日有卖出成交。";
let zeroLine = false;
if (factorKey) {
const curve = (res.factor_curves ?? []).find((f) => f.name === factorKey);
if (curve) {
const idx = (res.factor_curves ?? []).findIndex((f) => f.name === factorKey);
series = [factorSeries(curve, idx)];
formatKey = factorFormatKey(curve);
note = factorCurveNote(curve);
// 因子曲线上的买卖点:把组合的成交日标在因子曲线上,直接看「买卖发生在什么水平」
markers = portfolioMarkers(res.fills, new Set(curve.points.map((p) => p.date)));
}
} else if (symKey) {
const curve = (res.symbol_curves ?? []).find((c) => c.symbol === symKey);
if (curve) {
series = symbolSeries(curve);
markers = symbolMarkers(curve);
formatKey = "pct2";
note =
`${curve.symbol} 持仓期间的累计收益率(%,以建仓日收盘为 0% 基准,按日复利),` +
"只在该股持仓的交易日落点;买卖点为实际成交。";
zeroLine = true;
}
} else if (current?.key === "drawdown") {
series = drawdownSeries(res);
markers = [];
formatKey = "pct2";
note = "回撤(%):净值相对历史最高点的跌幅,越负越深。";
zeroLine = true;
} else if (current?.key === "monthly") {
series = monthlySeries(res);
markers = [];
formatKey = "pct2";
note = "月度收益(%):每个月末相对上月末的净值变化。";
zeroLine = true;
}
const seriesLabel = series[0]?.label ?? current?.label ?? "曲线";
return (
<>
<div className="between" style={{ margin: "4px 0 12px", gap: 12, flexWrap: "wrap" }}>
<div className="row" style={{ gap: 8, flexWrap: "wrap" }}>
<b style={{ fontSize: 16 }}>曲线放大</b>
<Pill tone="accent">{experimentKindLabel(detail.kind)}</Pill>
<span className="mono hint">{id}</span>
{detail.created_at ? (
<span className="hint">归档于 {String(detail.created_at).slice(0, 19).replace("T", " ")}</span>
) : null}
</div>
<div className="row" style={{ gap: 8 }}>
<Link className="btn btn--sm" href={`/experiments/${encodeURIComponent(id)}`}>
<span>打开归档详情</span>
</Link>
</div>
</div>
<div className="row" style={{ gap: 6, flexWrap: "wrap", marginBottom: 10 }}>
{options.map((o) => (
<Link
key={o.key}
className={o.key === current?.key ? "btn btn--sm btn--primary" : "btn btn--sm"}
href={`/charts/${encodeURIComponent(id)}?s=${encodeURIComponent(o.key)}`}
title={o.hint}
>
<span>{o.label}</span>
</Link>
))}
</div>
<Card
icon="chartLine"
title={seriesLabel}
tools={
<Pill tone={series[0]?.type === "bar" ? "violet" : "pos"}>
{series[0]?.data.length ?? 0} 个点
{markers.length ? ` · ${markers.length} 个买卖标注` : ""}
</Pill>
}
>
<LwChart
series={series}
markers={markers}
height={CHART_HEIGHT}
formatKey={formatKey}
zeroLine={zeroLine}
ariaLabel={`${seriesLabel}(放大)`}
/>
<div className="hint" style={{ marginTop: 8 }}>
{note} 拖动 / 滚轮可缩放,双击图例可临时隐藏曲线;地址栏 URL 可直接分享或收藏
(换设备打开还原同一张图,因为数据来自归档快照)。
</div>
</Card>
{res.summary ? (
<Card icon="gauge" title="这次回测的关键指标(与曲线同一份快照)">
<div className="row" style={{ gap: 16, flexWrap: "wrap" }}>
<span>
区间 <span className="mono">{res.summary.start}</span> ~{" "}
<span className="mono">{res.summary.end}</span>
</span>
<span>
总收益{" "}
<b className={res.summary.total_return_pct >= 0 ? "tone-pos" : "tone-neg"}>
{res.summary.total_return_pct.toFixed(2)}%
</b>
</span>
<span>年化 {res.summary.annual_return_pct.toFixed(2)}%</span>
<span>Sharpe {res.summary.sharpe.toFixed(3)}</span>
<span>最大回撤 {res.summary.max_drawdown_pct.toFixed(2)}%</span>
<span>期末权益 {fmtNum(res.summary.final_equity)}</span>
<span className="hint" style={{ color: CHART.faint }}>
共 {res.summary.total_trades} 笔成交
</span>
</div>
</Card>
) : null}
</>
);
}
+23 -57
View File
@@ -15,12 +15,8 @@ import {
Banner,
Progress,
Signed,
BacktestMetrics,
MonthlyReturnsTable,
UnimplementedNote,
} from "@/components/ui";
import { LwChart, type LwSeries } from "@/components/charts/LwChart";
import { CHART, fmtNum, fmtPct } from "@/components/charts/theme";
import { BacktestResultView } from "@/components/BacktestResultView";
import { Icon } from "@/components/icons";
interface Pick {
@@ -37,6 +33,8 @@ export default function ComposePage() {
const [start, setStart] = useState("");
const [end, setEnd] = useState("");
const [result, setResult] = useState<BacktestResult | null>(null);
/** 本次结果的归档 id:结果区的「新页面放大」从归档读同一份数据 */
const [archiveId, setArchiveId] = useState<string | null>(null);
const [running, setRunning] = useState(false);
const [jobId, setJobId] = useState("");
const [error, setError] = useState("");
@@ -88,6 +86,7 @@ export default function ComposePage() {
setError("");
setJobId("");
setResult(null);
setArchiveId(null); // 新一次运行:先清掉上一次的归档 id,避免放大到旧结果
try {
const spec: ResearchSpec = {
type: "backtest",
@@ -102,6 +101,7 @@ export default function ComposePage() {
const out = await waitJob<BacktestResult>(job_id);
if (out.status === "success" && out.result) {
setResult(out.result);
setArchiveId(out.experimentId ?? null);
} else {
setError(`任务${out.status}${out.error ? `:${out.error}` : ""}`);
}
@@ -289,7 +289,13 @@ export default function ComposePage() {
{error ? <div style={{ marginTop: 12 }}><Banner tone="error">{error}</Banner></div> : null}
</Card>
{result ? <ResultView result={result} params={{ picks, topN, rebalance, excludeSt, start, end }} /> : null}
{result ? (
<ResultView
result={result}
params={{ picks, topN, rebalance, excludeSt, start, end }}
archiveId={archiveId}
/>
) : null}
</>
);
}
@@ -297,36 +303,17 @@ export default function ComposePage() {
function ResultView({
result,
params,
archiveId,
}: {
result: BacktestResult;
params: { picks: Pick[]; topN: number; rebalance: string; excludeSt: boolean; start: string; end: string };
archiveId?: string | null;
}) {
const s = result.summary;
// 曲线数据点:LwChart 的 time 用 "YYYY-MM-DD" 字符串,向后端 CurvePoint 的 date 直接映射
const equitySeries: LwSeries[] = [
{
key: "equity",
label: "净值",
type: "area",
color: CHART.pos,
data: result.equity_curve.map((p) => ({ time: p.date, value: p.value })),
},
];
const drawdownSeries: LwSeries[] = [
{
key: "drawdown",
label: "回撤",
type: "line",
color: CHART.neg,
data: result.drawdown.map((p) => ({ time: p.date, value: p.value })),
},
];
return (
<>
<div className="between" style={{ margin: "6px 0 14px" }}>
<div className="row">
<div className="row" style={{ flexWrap: "wrap", gap: 8 }}>
<Pill tone="pos" icon="check">
回测完成
</Pill>
@@ -334,41 +321,20 @@ function ResultView({
<Pill>{params.rebalance === "monthly" ? "月度调仓" : "周度调仓"}</Pill>
{params.excludeSt ? <Pill>剔除 ST</Pill> : null}
<Pill>{params.start} ~ {params.end}</Pill>
<Pill>{params.picks.length} 个因子</Pill>
</div>
<div className="row" style={{ fontSize: 22, fontWeight: 700 }}>
区间收益 <Signed value={s.total_return_pct} suffix="%" />
</div>
</div>
<BacktestMetrics s={s} />
<div className="chart-grid">
<Card icon="chartLine" title="净值曲线" tools={<Pill tone="pos">期末 {s.final_equity.toLocaleString()}</Pill>}>
<LwChart
series={equitySeries}
height={300}
valueFormat={(v) => fmtNum(v, 2)}
ariaLabel="净值曲线"
emptyHint="该区间没有净值数据"
/>
</Card>
<Card icon="chartLine" title="回撤(%)" tools={<Pill tone="neg">最大 {s.max_drawdown_pct.toFixed(2)}%</Pill>}>
<LwChart
series={drawdownSeries}
height={300}
zeroLine
valueFormat={(v) => fmtPct(v, 2)}
ariaLabel="回撤曲线"
emptyHint="该区间没有回撤数据"
/>
</Card>
</div>
<Card icon="calendar" title="月度收益(%)">
<MonthlyReturnsTable rows={result.monthly_returns} />
</Card>
<UnimplementedNote items={result.unimplemented} />
{/* 与「回测组合」共用同一个结果视图:买卖理由、因子曲线、放大入口都在那里,
两处各写一份迟早出现「同一个结果两套图」的口径漂移 */}
<BacktestResultView
result={result}
name="因子组合"
archive={archiveId ? { id: archiveId } : undefined}
/>
</>
);
}
+124
View File
@@ -2185,3 +2185,127 @@ button.chip:hover {
min-width: 0;
flex: 1 1 320px;
}
/* ---------- 作业反馈条(JobProgress):点「运行」后必须立刻看得见 ---------- */
.job-progress {
margin: 12px 0;
padding: 12px 14px;
border: 1px solid var(--line);
border-left: 3px solid var(--accent);
border-radius: var(--r-md);
background: var(--surface-2);
}
.job-progress--run {
border-left-color: var(--accent);
background: var(--accent-soft);
}
.job-progress--ok {
border-left-color: var(--pos);
background: rgba(61, 220, 151, 0.08);
}
.job-progress--bad {
border-left-color: var(--neg);
background: rgba(255, 106, 118, 0.08);
}
.job-progress__head {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--sp-2);
}
.job-progress__title {
font-weight: 600;
font-size: var(--fs-sm);
}
.job-progress__meta {
display: inline-flex;
flex-wrap: wrap;
align-items: center;
gap: var(--sp-2);
font-size: var(--fs-xs);
color: var(--text-2);
font-variant-numeric: tabular-nums;
}
.job-progress__actions {
display: inline-flex;
flex-wrap: wrap;
align-items: center;
gap: 6px;
margin-left: auto;
}
.job-progress__err {
margin-top: 8px;
padding: 8px 10px;
border-radius: var(--r-sm);
background: var(--surface-1);
color: var(--neg);
font-size: var(--fs-xs);
white-space: pre-wrap;
overflow-wrap: anywhere;
}
/* ---------- 买卖说明:分段筛选 + 理由单元格 ---------- */
.seg {
display: inline-flex;
border: 1px solid var(--line);
border-radius: var(--r-sm);
overflow: hidden;
}
.seg__btn {
appearance: none;
border: 0;
background: var(--surface-2);
color: var(--text-2);
font: inherit;
font-size: var(--fs-xs);
padding: 5px 10px;
cursor: pointer;
}
.seg__btn + .seg__btn {
border-left: 1px solid var(--line);
}
.seg__btn.is-on {
background: var(--accent-soft);
color: var(--accent-strong);
font-weight: 600;
}
.th-sort {
appearance: none;
border: 0;
background: none;
color: inherit;
font: inherit;
cursor: pointer;
padding: 0;
}
.reason-cell {
display: flex;
flex-direction: column;
gap: 5px;
min-width: 260px;
}
.reason-cell__head {
display: flex;
align-items: flex-start;
gap: 6px;
flex-wrap: wrap;
}
.reason-cell__text {
font-size: var(--fs-xs);
color: var(--text-1);
overflow-wrap: anywhere;
}
.reason-cell__facts {
display: flex;
flex-wrap: wrap;
gap: 4px;
}
.reason-brief {
font-size: var(--fs-xs);
color: var(--text-2);
max-width: 160px;
overflow-wrap: anywhere;
}
.nowrap {
white-space: nowrap;
}
+131 -68
View File
@@ -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>
);
}
+308
View File
@@ -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>
);
}
+35 -3
View File
@@ -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<{
+152
View File
@@ -0,0 +1,152 @@
/**
* 回测结果曲线 → 图表序列的**唯一构造处**。
*
* 为什么单独抽出来:同一条曲线会在三个地方出现 —— 回测结果页、归档详情页、
* 以及「新页面放大」的 `/charts/{id}`。三处各写一份格式化逻辑,迟早出现
* 「同一张图两个页面数值口径不一样」。这里把每类曲线的取数、颜色、数值格式、
* 买卖点标注统一成函数,页面只管摆放。
*/
import type { LwFormatKey, LwSeries, LwMarker } from "@/components/charts/LwChart";
import { CHART, fmtNum } from "@/components/charts/theme";
import type { ActionRecord, BacktestResult, FactorCurve, SymbolCurve } from "@/lib/types";
/** 因子值的量纲说明与人读格式("%" / "倍数" / "小数",None = 无量纲) */
export function factorValueFormat(curve: FactorCurve): (v: number) => string {
if (curve.unit === "%") return (v) => `${v.toFixed(3)}%`;
if (curve.unit === "倍数") return (v) => `${v.toFixed(3)}×`;
// 小数:原值 0.15 = 15% —— 图上按百分数显示更好读,但标签里会注明「原值为小数」
if (curve.unit === "小数") return (v) => `${(v * 100).toFixed(2)}%`;
return (v) => v.toFixed(4);
}
/**
* 因子曲线的格式标识(给 Server Component 用)。
*
* 与 `factorValueFormat` 必须一致:两处定义同一件事会漂移,因此这里直接按 unit 分支,
* 且在单测/自检里比对两者的输出。
*/
export function factorFormatKey(curve: FactorCurve): LwFormatKey {
if (curve.unit === "%") return "pct3";
if (curve.unit === "倍数") return "times3";
if (curve.unit === "小数") return "frac-pct";
return "auto4";
}
/** 因子曲线口径的完整说明(图上必须写,避免把「持仓加权平均」读成别的口径) */
export function factorCurveNote(curve: FactorCurve): string {
const unitNote =
curve.unit === "小数"
? "原值为小数(0.15 即 15%),图上按百分数显示"
: curve.unit
? `单位:${curve.unit}`
: "无量纲";
const dir = curve.direction === "lower_is_better" ? "越低越好" : "越高越好";
return (
`口径:每个交易日**当日持仓按市值加权平均**的因子原始值(不做 z-score、不按方向取反),` +
`空仓日不落点。${unitNote};方向 ${dir}。`
);
}
/** 因子曲线序列 */
export function factorSeries(curve: FactorCurve, index = 0): LwSeries {
const colors = [CHART.accent, CHART.violet, CHART.pos, CHART.neg, CHART.amber];
return {
key: `factor-${curve.name}`,
label: `${curve.label}(持仓加权)`,
type: "line",
lineWidth: 2,
color: colors[index % colors.length],
data: curve.points.map((p) => ({ time: p.date, value: p.value })),
lastValueVisible: true,
};
}
/** 同一日多次成交合并成一个标记(图上不叠字) */
export function portfolioMarkers(
fills: ActionRecord[] | undefined,
validDates?: Set<string>
): LwMarker[] {
const byDate = new Map<string, { BUY: boolean; SELL: boolean }>();
for (const f of fills ?? []) {
if (validDates && !validDates.has(f.date)) continue;
const cur = byDate.get(f.date) ?? { BUY: false, SELL: false };
cur[f.signal] = true;
byDate.set(f.date, cur);
}
const out: LwMarker[] = [];
for (const [time, kinds] of byDate) {
if (kinds.BUY) out.push({ time, kind: "BUY", text: "买" });
if (kinds.SELL) out.push({ time, kind: "SELL", text: "卖" });
}
return out.sort((a, b) => (a.time < b.time ? -1 : 1));
}
export function equitySeries(result: BacktestResult): LwSeries[] {
return [
{
key: "equity",
label: "组合净值(元)",
type: "area",
color: CHART.pos,
data: result.equity_curve.map((p) => ({ time: p.date, value: p.value })),
lastValueVisible: true,
},
];
}
export function drawdownSeries(result: BacktestResult): LwSeries[] {
return [
{
key: "dd",
label: "回撤(%)",
type: "area",
color: CHART.neg,
data: result.drawdown.map((p) => ({ time: p.date, value: p.value })),
lastValueVisible: true,
},
];
}
export function symbolSeries(curve: SymbolCurve): LwSeries[] {
return [
{
key: "sym",
label: `${curve.symbol} 持仓期累计收益(%)`,
type: "area",
color: CHART.accent,
data: curve.points.map((p) => ({ time: p.date, value: p.value })),
lastValueVisible: true,
},
];
}
export function symbolMarkers(curve: SymbolCurve): LwMarker[] {
const dates = new Set(curve.points.map((p) => p.date));
return (curve.marks ?? [])
.filter((a) => dates.has(a.date))
.map((a) => ({
time: a.date,
kind: a.signal,
text: a.signal === "BUY" ? "买" : "卖",
}));
}
/** 月度收益(柱状) */
export function monthlySeries(result: BacktestResult): LwSeries[] {
return [
{
key: "monthly",
label: "月度收益(%)",
type: "bar",
color: CHART.accent,
data: result.monthly_returns.map((m) => ({
time: `${m.year}-${String(m.month).padStart(2, "0")}-01`,
value: m.return_pct,
})),
lastValueVisible: true,
},
];
}
export const equityValueFormat = (v: number) => fmtNum(v);
+77 -1
View File
@@ -191,6 +191,59 @@ export interface MarkPoint {
}
/** 交易意图与成交记录(Signal ↔ Fill,v3 §20.3)。signal 为空字符串表示组合级提示。 */
/**
* 一次交易意图 / 成交的**结构化理由**(引擎给出的真实数字)。
*
* `code` 是封闭的原因分类(后端 `quant/trade_reasons.py`),前端据此筛选,
* 不去解析 `text`;`data` 里的每个数字都来自引擎当时的计算 —— 界面只展示,不推算。
*/
export interface TradeReason {
code: string;
text: string;
data: {
rank?: number;
total?: number;
top_n?: number;
score?: number;
/** 各因子当时的**原始值**(key = 因子引擎键,可能是参数化键) */
factors?: Record<string, number>;
hold_days?: number;
tmin?: number;
tmax?: number;
price?: number;
budget?: number;
min_commission?: number;
close?: number;
prev_close?: number;
close_prev_ratio?: number;
limit_ratio?: number;
return_pct?: number;
/** false = 该股已不在候选池(被股票池/条件过滤) */
in_pool?: boolean;
[k: string]: unknown;
};
}
/** 原因分类的中文短标签(与后端 REASON_LABELS 对齐) */
export const REASON_LABELS: Record<string, string> = {
buy_enter_topn: "按名次建仓",
buy_defer_filled: "顺延后成交",
buy_skip_limit_up: "涨停未买",
buy_skip_halted: "停牌未买",
buy_skip_no_cash: "现金不足",
buy_skip_min_commission: "不足最低佣金",
sell_drop_topn: "跌出 TopN",
sell_force_tmax: "持有超 Tmax",
sell_defer_tmin: "Tmin 保护暂留",
sell_defer_halted: "停牌未卖",
sell_defer_limit_down: "跌停未卖",
};
export function reasonLabel(code?: string | null): string {
if (!code) return "—";
return REASON_LABELS[code] ?? code;
}
export interface ActionRecord {
date: string;
symbol: string;
@@ -200,6 +253,17 @@ export interface ActionRecord {
filled: boolean;
reject_reason?: string | null;
price?: number | null;
reason?: TradeReason | null;
}
/** 单个因子的时间序列(回测期内持仓组合加权平均的**原始值**) */
export interface FactorCurve {
name: string;
label: string;
direction: string;
/** 量纲:% / 倍数 / 小数(小数意味着 0.15 = 15%,图上不换算) */
unit?: string | null;
points: CurvePoint[];
}
/** 个股收益率曲线 + 该股买卖点标注 */
@@ -219,11 +283,23 @@ export interface BacktestResult {
monthly_returns: { year: number; month: number; return_pct: number }[];
yearly_returns: { year: number; return_pct: number }[];
positions: { date: string; symbol: string; name?: string | null; weight: number }[];
trades: { entry_date: string; exit_date: string; symbol: string; name?: string | null; entry_price?: number; exit_price?: number; return_pct: number }[];
trades: {
entry_date: string;
exit_date: string;
symbol: string;
name?: string | null;
entry_price?: number;
exit_price?: number;
return_pct: number;
entry_reason?: TradeReason | null;
exit_reason?: TradeReason | null;
}[];
selection_history?: { date: string; symbol: string; name?: string | null; rank: number; score: number }[];
signal_history?: ActionRecord[];
fills?: ActionRecord[];
symbol_curves?: SymbolCurve[];
/** 策略用到的每个因子的时间序列(持仓加权平均原始值):解释买卖依据 */
factor_curves?: FactorCurve[];
turnover_pct: number;
unimplemented: string[];
config_snapshot: Record<string, unknown>;