问题不是"不好看",而是**可测量的错位**:同一行里原生 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 小屏)。
497 lines
19 KiB
TypeScript
497 lines
19 KiB
TypeScript
"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}
|
||
</>
|
||
);
|
||
}
|