Files
qlib/frontend/web/app/backtest/page.tsx
T
Simon 9aaca12751 fix(web): 控件对齐与输入友好整改(控件高度令牌化 + 对齐自检脚本)
问题不是"不好看",而是**可测量的错位**:同一行里原生 date 输入 38.8px、数字输入
36.8px、按钮 34px;16px 的勾选框与 36.8px 的下拉同排;因子行的下拉与权重框没有
可见标签;列表里的勾选框点不中;参数非法时「运行回测」直接置灰且不说原因。

根因:控件高度靠「上下 padding + 行高」拼出来,而 input / select / button 的原生行高
各不相同(Chrome 的 date 还会多 2px),必然参差;加上各处内联像素宽度
(style={{width:220}}、flex:1)与自搓布局,列自然对不齐。

改动:
- 新增控件高度令牌 --ctl-h-sm/md/lg(28/34/38px)与 --ctl-px,.input/select/.btn/
  .icon-btn/date 统一显式 height(不再拼 padding);原生 checkbox/radio 统一 16px,
  点击热区交给外层 label(.check/.radio-row/.check--cell),表格整格可点
- 因子行、选股条件行改为「表头 + CSS 栅格」成列对齐,列宽由样式决定,
  去掉内联像素宽度与 flex 拉伸,配 aria-label 供读屏分辨重复行
- 表单友好化:错误提示改为**失焦或提交后**才出现(清空重填的瞬间不再标红);
  提交被拦下时一次展开全部行内错误 + 自动聚焦并滚动到第一个问题字段;
  运行/保存按钮不再因参数非法而置灰(灰按钮不说原因 = 看起来不可点却无响应),
  改为可点击并讲清原因;补齐 topN/costs 两处「产生了却没人显示」的行内错误落点
- 数值字段补 inputMode/step/min/max 与单位、取值范围提示;工具条检索/筛选用
  .input--search/.input--filter/.input--picker 类,不再写内联宽度
- /experiments 筛选无结果的空态与「暂无实验」区分开(原文案会让人以为归档丢了)
- 同一页面可能挂两份表单:radio name 与 label/for 加表单实例前缀(useId),
  否则两边单选互相取消、label 指错控件
- 新增 scripts/verify_ui_alignment.py:系统 Chrome + 原生 CDP(仅标准库,
  独占随机端口与临时 profile),按 7 个页面 × 1500/375px 检查同排等高、
  高度取值归一、点击目标、标签与无障碍名、字号圆角一致、尺寸匹配内容、
  横向溢出、提示裁切;本次基线 108/32 → 现 140/140

验证:pytest 388 passed、ruff 全绿(顺带清掉 qlib_verify.py 一处死代码)、
tsc 0 错误、图表单测 7 passed、next build 成功、契约自检 59/59、
对齐自检 140/140(含 375px 小屏)。
2026-09-27 09:01:55 +08:00

497 lines
19 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";
/**
* 选股回测页 —— 研究闭环里的「定规则 + 验证」环节。
*
* 本页承担三种进入方式(都可分享 URL):
* - 直接进入:从默认参数开始调;
* - `?strategy=STG-xxx`:从策略库载入一个已保存策略(可微调、可另存);
* - `?from_selection=SEL-xxx`:从选股结果直通过来,预填当时的条件/因子/TopN
* (解决「选出来的股票要手工抄到回测页」的断点);
* - `?from_experiment=EXP-xxx`:从实验详情「以此参数再跑一次」进来。
*
* 交互改进:参数区与说明/公式同屏(说明随参数实时更新,由后端推导);
* 「保存为策略」把当前参数存进策略库;结果区所有股票代码都带名称且可点进个股页。
*/
import { Suspense, useEffect, useMemo, useRef, useState } from "react";
import Link from "next/link";
import { useSearchParams } from "next/navigation";
import { apiGet, apiPost } from "@/lib/api";
import { STAGE_LABEL, submitJob, waitJob } from "@/lib/jobs";
import { recentRange } from "@/lib/dates";
import type {
ActionRecord,
BacktestResult,
FactorMeta,
ResearchCondition,
ResearchSpec,
SelectionResult,
StrategyDefinition,
SymbolCurve,
} from "@/lib/types";
import { PageHeader, Card, Pill, Btn, Banner, Empty, Loading } from "@/components/ui";
import {
StrategyParamsForm,
casePreset,
emptyParams,
paramsFromSpec,
paramsFromStrategy,
strategyFromParams,
validateParams,
type StrategyParams,
} from "@/components/StrategyParamsForm";
import { StrategyDocCard } from "@/components/StrategyDocCard";
import { BacktestResultView } from "@/components/BacktestResultView";
import { adjustLabel } from "@/lib/labels";
import { useStrategyDoc } from "@/lib/strategy";
import { SymbolLink, useSymbolNames } from "@/lib/symbols";
type LoadState = { kind: "none" | "strategy" | "selection" | "experiment"; label: string; note?: string };
/**
* Next 15 要求使用 `useSearchParams` 的组件处于 Suspense 边界内,
* 否则静态渲染阶段会报错(本页用 query 决定「从策略/选股/实验载入」)。
*/
export default function BacktestPage() {
return (
<Suspense fallback={<Loading label="加载回测页…" />}>
<BacktestInner />
</Suspense>
);
}
function BacktestInner() {
const search = useSearchParams();
const strategyId = search.get("strategy");
const selectionId = search.get("from_selection");
const experimentId = search.get("from_experiment");
const range = useMemo(() => recentRange(), []);
const [params, setParams] = useState<StrategyParams | null>(null);
const [factors, setFactors] = useState<FactorMeta[]>([]);
const [loaded, setLoaded] = useState<LoadState>({ kind: "none", label: "" });
const [result, setResult] = useState<BacktestResult | null>(null);
const [running, setRunning] = useState(false);
const [jobId, setJobId] = useState("");
const [stage, setStage] = useState("");
const [elapsed, setElapsed] = useState(0);
const [error, setError] = useState("");
const [notice, setNotice] = useState("");
const [saving, setSaving] = useState(false);
const [lastExp, setLastExp] = useState<string | null>(null);
const [archiveId, setArchiveId] = useState("");
const [restoring, setRestoring] = useState(false);
const bootstrapped = useRef(false);
/* ---------- 初始化:因子表 + 默认/来源参数 ---------- */
useEffect(() => {
let alive = true;
apiGet<FactorMeta[]>("/factors")
.then((list) => alive && setFactors(list))
.catch((e: Error) => alive && setError(e.message));
return () => {
alive = false;
};
}, []);
useEffect(() => {
if (bootstrapped.current) return;
bootstrapped.current = true;
const base = emptyParams(range);
try {
const prev = window.localStorage.getItem("qlib:last-backtest-experiment");
if (prev) setLastExp(prev);
} catch {
/* localStorage 不可用(隐私模式):只是少一个便捷入口,不影响主流程 */
}
(async () => {
// 1) 策略库载入
if (strategyId) {
try {
const st = await apiGet<StrategyDefinition>(`/strategies/${encodeURIComponent(strategyId)}`);
setParams({ ...paramsFromStrategy(st, base), start: base.start, end: base.end });
setLoaded({
kind: "strategy",
label: `来自策略库:${st.name}(${st.id})`,
note: "参数已载入;改完可「另存为新策略」或直接运行回测。",
});
return;
} catch (e) {
setError(`策略 ${strategyId} 载入失败:${(e as Error).message}`);
}
}
// 2) 选股结果直通:把当时的口径原样搬过来
if (selectionId) {
try {
const sel = await apiGet<SelectionResult>(`/selections/${encodeURIComponent(selectionId)}`);
// 选股把当时的查询原样存进 config_snapshot(复现用),这里据此预填
const snapQ = (sel.config_snapshot ?? {}) as {
universe?: { exclude_st?: boolean; min_listing_days?: number };
price_adjustment?: "none" | "qfq" | "hfq";
factors?: { name: string; weight: number }[];
conditions?: ResearchCondition[];
top_n?: number | null;
};
const topNQ = snapQ.top_n ?? base.topN;
setParams({
...base,
factors: snapQ.factors?.length ? snapQ.factors.map((f) => ({ ...f })) : base.factors,
conditions: (snapQ.conditions ?? []).map((c) => ({ ...c })),
topN: topNQ,
holdX: Math.max(1, Math.min(topNQ, base.holdX)),
excludeSt: snapQ.universe?.exclude_st ?? base.excludeSt,
minListingDays: snapQ.universe?.min_listing_days ?? base.minListingDays,
priceAdjustment: snapQ.price_adjustment ?? base.priceAdjustment,
start: base.start,
end: base.end,
});
setLoaded({
kind: "selection",
label: `来自选股 ${sel.as_of_date}(${sel.method})`,
note:
"已把该次选股的**规则**(条件/因子/TopN/口径)预填为回测参数。" +
"注意:回测会在每个择股日按同一规则重新选股,而不是固定持有该次选出的这批股票。",
});
return;
} catch (e) {
setError(`选股结果 ${selectionId} 载入失败:${(e as Error).message}`);
}
}
// 3) 实验参数复用
if (experimentId) {
try {
const exp = await apiGet<{ result?: BacktestResult & { config_snapshot?: Record<string, unknown> } }>(
`/experiments/${encodeURIComponent(experimentId)}`
);
const snap = exp.result?.config_snapshot as Partial<ResearchSpec> | undefined;
if (snap) {
setParams(paramsFromSpec(snap, base));
setLoaded({
kind: "experiment",
label: `来自实验 ${experimentId}`,
note: exp.result?.equity_curve?.length
? `已复刻该实验参数,并直接展示它的归档结果(未重跑)。归档快照请到 /experiments/${experimentId} 查看;改参数后可再跑一次并与它对比。`
: "已复刻该实验的参数,可改后重跑并与原实验对比。",
});
// 实验已归档完整结果:直接展示,省一次几分钟的重跑
if (exp.result?.equity_curve?.length) {
setResult(exp.result);
setLastExp(experimentId);
setArchiveId(experimentId); // 提示条里给「打开归档」入口
}
return;
}
setError(`实验 ${experimentId} 没有可复用的参数快照`);
} catch (e) {
const msg = (e as Error).message;
setError(`实验 ${experimentId} 载入失败:${msg}`);
// 归档可能已被删除:清掉 localStorage 里的指针,避免「载入上次结果」每次都给坏链接。
// 只清指针(不影响参数区),并且只有在确实是 404 时才清。
if (msg.includes("404")) {
try {
if (window.localStorage.getItem("qlib:last-backtest-experiment") === experimentId) {
window.localStorage.removeItem("qlib:last-backtest-experiment");
setLastExp(null);
}
} catch {
/* localStorage 不可用:无需清理 */
}
}
}
}
setParams(base);
})();
// 仅首次进入时按 URL 载入
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// 提交被拦下过 → 立即展开所有校验错误(否则用户只看到顶部一条,不知道还有哪些字段有问题)
const [revealErrors, setRevealErrors] = useState(false);
const errors = params ? validateParams(params, { requireMeta: params.name.trim() !== "" }) : {};
const blocking = ["topN", "holdX", "factors", "mMonths", "yMonths", "costs", "capital", "period", "conditions"].filter(
(k) => errors[k]
);
const doc = useStrategyDoc(params);
async function run() {
if (!params) return;
if (blocking.length) {
setError(errors[blocking[0]]);
setRevealErrors(true);
// 焦点管理:直接把人带到出问题的控件上,而不是只丢一句提示让人自己找
requestAnimationFrame(() => {
const el = document.querySelector<HTMLElement>(".input--invalid, .field--invalid input");
el?.focus();
el?.scrollIntoView({ block: "center", behavior: "smooth" });
});
return;
}
setRunning(true);
setError("");
setNotice("");
setJobId("");
setArchiveId("");
setResult(null);
try {
const spec = {
type: "backtest" as const,
universe: { exclude_st: params.excludeSt, min_listing_days: params.minListingDays },
price_adjustment: params.priceAdjustment,
factors: params.factors.filter((f) => f.name.trim() !== ""),
conditions: params.conditions.filter((c) => c.field.trim() !== ""),
selection: {
top_n: params.topN,
hold_top_x: params.holdX,
allow_substitute: params.fillPolicy === "substitute",
defer_buy: params.fillPolicy === "defer",
},
rebalance: params.rebalance,
selection_interval_months:
params.mMonths > 0 ? params.mMonths : params.yMonths > 0 ? params.yMonths : null,
rebalance_interval_months:
params.yMonths > 0 ? params.yMonths : params.mMonths > 0 ? params.mMonths : null,
costs: {
commission_rate: params.commission / 100,
stamp_tax_rate: params.stamp / 100,
slippage_rate: params.slippage / 100,
min_commission: params.minCommission,
},
initial_capital: params.capital,
period: [params.start, params.end] as [string, string],
};
const { job_id } = await submitJob(spec);
setJobId(job_id);
setStage("queued");
const out = await waitJob<BacktestResult>(job_id, 900_000, (info) => {
setStage(info.stage);
setElapsed(info.elapsedMs);
});
if (out.status === "success" && out.result) {
setResult(out.result);
const exp = out.experimentId ?? null;
if (exp) {
setLastExp(exp);
setArchiveId(exp);
try {
window.localStorage.setItem("qlib:last-backtest-experiment", exp);
} catch {
/* 忽略:仅影响下次进入的便捷入口 */
}
}
setNotice(
exp
? `回测完成,已归档为实验 ${exp}(可在「实验对比」页与其它版本对比)。`
: "回测完成,已自动归档到「实验对比」页。"
);
} else {
setError(`任务${out.status}${out.error ? `:${out.error}` : ""}`);
}
} catch (e) {
setError((e as Error).message);
} finally {
setRunning(false);
setJobId("");
}
}
/** 载入上次回测结果:避免刷新页面后必须重跑一个几分钟的作业 */
async function loadLast() {
if (!lastExp) return;
setRestoring(true);
setError("");
try {
const exp = await apiGet<{ result?: BacktestResult }>(`/experiments/${encodeURIComponent(lastExp)}`);
if (exp.result?.equity_curve?.length) {
setResult(exp.result);
setArchiveId(lastExp);
setNotice(`已载入上次归档结果(${lastExp}),未重新执行作业。`);
} else {
setError(`实验 ${lastExp} 没有可载入的回测结果`);
}
} catch (e) {
setError((e as Error).message);
} finally {
setRestoring(false);
}
}
async function saveAsStrategy() {
if (!params) return;
const need = validateParams(params, { requireMeta: true });
if (Object.keys(need).length) {
setError(Object.values(need)[0]);
return;
}
setSaving(true);
setError("");
try {
const saved = await apiPost<StrategyDefinition>("/strategies", strategyFromParams(params));
setNotice(`已保存到策略库:「${saved.name}」(${saved.id})—— 可在策略库一键回测/编辑。`);
setLoaded({ kind: "strategy", label: `已保存为策略 ${saved.name}(${saved.id})` });
} catch (e) {
setError((e as Error).message);
} finally {
setSaving(false);
}
}
return (
<>
<PageHeader
title="选股回测"
sub="两级截断择股(候选池 n → 持仓 x)+ 择股/调仓双周期(m/y),收盘价撮合,买卖点与个股收益曲线可视化,结果自动归档可复现。"
actions={
<div className="row" style={{ gap: 8 }}>
<Link href="/strategies" className="btn">
<span>策略库</span>
</Link>
<Link href="/experiments" className="btn">
<span>实验对比</span>
</Link>
</div>
}
/>
{lastExp && !result && !running ? (
<div className="sticky-bar" style={{ position: "static" }}>
<Pill tone="accent" icon="archive">
上次结果
</Pill>
<span className="hint">
本机上次回测已归档为实验 <span className="mono">{lastExp}</span>;
直接载入可省去重跑(全市场 8 年区间约 3~5 分钟)。
</span>
<Btn size="sm" icon="refresh" loading={restoring} disabled={restoring} onClick={loadLast}>
载入上次结果
</Btn>
<Btn
size="sm"
icon="x"
onClick={() => {
setLastExp(null);
try {
window.localStorage.removeItem("qlib:last-backtest-experiment");
} catch {
/* 忽略 */
}
}}
>
忽略
</Btn>
</div>
) : null}
{loaded.kind !== "none" ? (
<Banner tone="info">
{loaded.label}
{loaded.note ? ` · ${loaded.note}` : ""}
</Banner>
) : null}
{error ? <Banner tone="error">{error}</Banner> : null}
{notice ? (
<Banner tone="info">
{notice}
{archiveId ? (
<>
{" "}
<Link href={`/experiments/${encodeURIComponent(archiveId)}`} className="btn btn--sm">
<span>打开归档(完整快照)</span>
</Link>
</>
) : null}
</Banner>
) : null}
<Card
icon="gauge"
title="研究参数(Research Specification)"
tools={
<div className="row" style={{ gap: 8 }}>
<Btn icon="target" disabled={running || !params} onClick={() => params && setParams({ ...casePreset(range), name: params.name, description: params.description })}>
载入高股息案例默认参数
</Btn>
<Btn icon="book" loading={saving} disabled={running || saving || !params} onClick={saveAsStrategy}>
保存为策略
</Btn>
</div>
}
>
{params ? (
<>
<StrategyParamsForm
value={params}
onChange={setParams}
factorOptions={factors}
showMeta
showPeriod
disabled={running}
errors={errors}
revealErrors={revealErrors}
/>
<div className="sticky-bar" style={{ marginTop: 14, position: "static" }}>
<Btn variant="primary" icon="play" loading={running} disabled={running} onClick={run}>
{running ? "后台运行中…" : "运行回测"}
</Btn>
<span className="hint">
n={params.topN} → x={params.holdX} · m={params.mMonths} / y={params.yMonths} ·{" "}
{adjustLabel(params.priceAdjustment)} · {params.start} ~ {params.end}
</span>
{blocking.length ? (
<Pill tone="warn" icon="alert">
有 {blocking.length} 处参数需要修正
</Pill>
) : (
<Pill tone="pos" icon="check">
参数校验通过
</Pill>
)}
</div>
</>
) : (
<Loading label="准备参数…" />
)}
{running ? (
<div className="stages" style={{ marginTop: 12 }}>
{["data_loading", "selection", "backtesting", "analysis"].map((st) => {
const order = ["queued", "data_loading", "selection", "backtesting", "analysis", "done"];
const cur = order.indexOf(stage);
const mine = order.indexOf(st);
const cls = mine < cur ? "stage is-done" : mine === cur ? "stage is-active" : "stage";
return (
<span className={cls} key={st}>
{mine < cur ? "✓" : mine === cur ? "●" : "○"} {STAGE_LABEL[st] ?? st}
</span>
);
})}
<span className="hint">
作业 <span className="mono">{jobId || "排队中…"}</span> · 已用{" "}
{Math.round(elapsed / 1000)}s(全市场 8 年区间约 3~5 分钟,可离开本页,结果会归档)
</span>
</div>
) : null}
</Card>
<StrategyDocCard
doc={doc.doc}
loading={doc.loading}
error={doc.error}
title="策略说明与计算公式(随参数实时更新)"
/>
{!result && !running && !error ? (
<Card>
<Empty
icon="gauge"
title="尚未运行回测"
hint="可点「载入高股息案例默认参数」一键填充;调好后点「保存为策略」即可在策略库复用、对比。"
/>
</Card>
) : null}
{result ? <BacktestResultView result={result} name={params?.name ?? ""} /> : null}
</>
);
}