Files
qlib/frontend/web/app/backtest/page.tsx
T
Simon f13b34c59e feat(web): 字段库页 + 界面单位换算 + 因子目录参数化 + 选股策略条件表单
字段库与单位:
- `/fields`:字段库管理页(中文名/说明可改、可停用;kind 是引擎事实不可改);
  单位只在字段自己的阶梯里选(总市值 = 万元/亿元),越界 422 原样展示。
- `lib/units.ts`:界面单位 ⇄ 基准单位换算集中一处,条件输入按界面单位回显、
  提交前换回基准单位(引擎只认基准单位,库里存的也永远是基准单位)。
- `SelectionStrategyForm` 取代 `StrategyParamsForm`:一个策略只定义「怎么选」
  (股票池 + 因子 + 过滤条件),条件字段来自字段库接口而不是前端硬编码枚举。

因子参数化(名字即身份,界面不许藏):
- `/factors` 新增「参数」列与展开行:精确引擎键、每个参数的允许范围、来源、依赖列
  (依赖列标明「引擎事实,不可改」);说明文案从「不可修改」改为
  「改参数 = 新建参数化因子 = 新身份,旧因子/既有策略不变义」。
- 「新建参数化因子」卡片:模板下拉 + 受控窗口(min/max)+ 方向枚举下拉,
  实时预览规范键/中文名/渲染公式;后端 422 的原文原样展示,不静默截断。
- 因子下拉显示中文名(含参数)与短键,**value 一律是完整引擎键**;
  停用的因子从各页候选里消失(/strategies、/selection、/signals、/factors/compose),
  既有策略/归档仍按名字解析;`/factors/compose` 顺手修了勾选框点击目标过小。
- `/fields` 加提示:字段库只列内置因子名,参数化因子在 /factors 管理,
  选股策略条件下拉里会一并出现。

tsc --noEmit 0 error;verify_ui_alignment 8 页 160 项全过(另跑 3 个选因子页 51/54)。
2026-10-01 16:38:29 +08:00

656 lines
27 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";
/**
* 回测组合(/backtest)—— 选若干选股策略 + 填回测参数 → 保存为组合 / 直接运行。
*
* 2026-09 重构后这里是回测的**唯一产品入口**:
* - 选股策略(怎么选股)来自 /strategies;
* - 费率 / 复权口径来自 /settings(公共配置,本页只读展示);
* - 本页只定「回测时才定的参数」:起始资金、持仓数 N、持仓天数区间 [Tmin,Tmax]、
* 调仓时机(日/周/月)、回测区间。
*
* 进入方式(都可分享 URL):
* - 直接进入:空组合开始;
* - `?combo=CMB-xxx`:载入已保存组合;
* - `?strategy=STG-xxx`:预先把该选股策略勾进组合(从策略库「加入回测组合」过来)。
*/
import { Suspense, useCallback, useEffect, useMemo, useRef, useState } from "react";
import Link from "next/link";
import { useSearchParams } from "next/navigation";
import { apiDelete, apiGet, apiPost, apiPut } from "@/lib/api";
import { STAGE_LABEL, runSavedCombo, submitComboJob, waitJob } from "@/lib/jobs";
import { recentRange } from "@/lib/dates";
import type { BacktestCombo, BacktestResult, GlobalConfig, SelectionStrategy } from "@/lib/types";
import { PageHeader, Card, Pill, Btn, Banner, Empty, Loading, Field, Progress, BacktestMetrics } from "@/components/ui";
import { BacktestResultView } from "@/components/BacktestResultView";
import { adjustLabel } from "@/lib/labels";
export default function BacktestPage() {
return (
<Suspense fallback={<Loading label="加载回测组合页…" />}>
<BacktestInner />
</Suspense>
);
}
interface Draft {
name: string;
description: string;
strategyIds: string[];
initialCapital: number;
holdCount: number;
holdMinDays: number;
holdMaxDays: number | null; // null = 不强制了结
rebalanceFreq: "daily" | "weekly" | "monthly";
start: string;
end: string;
}
function emptyDraft(range: { start: string; end: string }): Draft {
return {
name: "",
description: "",
strategyIds: [],
initialCapital: 1_000_000,
holdCount: 20,
holdMinDays: 0,
holdMaxDays: null,
rebalanceFreq: "monthly",
start: range.start,
end: range.end,
};
}
function draftToCombo(d: Draft): BacktestCombo {
return {
name: d.name.trim(),
description: d.description.trim(),
strategy_ids: d.strategyIds,
initial_capital: d.initialCapital,
hold_count: d.holdCount,
hold_min_days: d.holdMinDays,
hold_max_days: d.holdMaxDays,
rebalance_freq: d.rebalanceFreq,
period: [d.start, d.end],
};
}
function comboToDraft(c: BacktestCombo): Draft {
return {
name: c.name,
description: c.description ?? "",
strategyIds: [...c.strategy_ids],
initialCapital: c.initial_capital,
holdCount: c.hold_count,
holdMinDays: c.hold_min_days,
holdMaxDays: c.hold_max_days,
rebalanceFreq: c.rebalance_freq,
start: c.period[0],
end: c.period[1],
};
}
/** 调仓时机中文标签(列表与摘要共用,避免两处写法漂移)。 */
const FREQ_LABEL: Record<Draft["rebalanceFreq"], string> = {
daily: "每日",
weekly: "每周",
monthly: "每月",
};
function validateDraft(d: Draft, strategies: SelectionStrategy[]): Record<string, string> {
const e: Record<string, string> = {};
if (!d.strategyIds.length) e.strategies = "至少选择一个选股策略";
else {
const missing = d.strategyIds.filter((id) => !strategies.some((s) => s.id === id));
if (missing.length) e.strategies = `以下选股策略已不存在,请移除:${missing.join(", ")}`;
}
if (d.holdCount < 1) e.holdCount = "持仓数 N 至少为 1";
if (d.holdMinDays < 0) e.holdMinDays = "Tmin 不能为负";
if (d.holdMaxDays !== null && d.holdMaxDays < 1) e.holdMaxDays = "Tmax 至少为 1 天";
if (d.holdMaxDays !== null && d.holdMinDays > 0 && d.holdMaxDays < d.holdMinDays)
e.holdMaxDays = `Tmax=${d.holdMaxDays} 不能小于 Tmin=${d.holdMinDays}`;
if (d.initialCapital < 10000) e.capital = "起始资金建议 ≥ 1 万";
if (!d.start || !d.end) e.period = "起止日期都必填";
else if (d.start >= d.end) e.period = "开始日期必须早于结束日期";
return e;
}
function BacktestInner() {
const search = useSearchParams();
const comboId = search.get("combo");
const presetStrategy = search.get("strategy");
const range = useMemo(() => recentRange(), []);
const [strategies, setStrategies] = useState<SelectionStrategy[]>([]);
const [config, setConfig] = useState<GlobalConfig | null>(null);
const [combos, setCombos] = useState<BacktestCombo[] | null>(null);
const [draft, setDraft] = useState<Draft>(emptyDraft(range));
const [savedComboId, setSavedComboId] = useState<string | null>(null);
const [result, setResult] = useState<BacktestResult | null>(null);
const [running, setRunning] = useState(false);
const [runningComboId, setRunningComboId] = useState<string | null>(null);
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 [deletingComboId, setDeletingComboId] = useState<string | null>(null);
const [revealErrors, setRevealErrors] = useState(false);
const bootstrapped = useRef(false);
/* ---------- 初始化:策略列表 + 公共配置 + 已保存组合 + 来源载入 ---------- */
useEffect(() => {
let alive = true;
Promise.all([
apiGet<SelectionStrategy[]>("/strategies"),
apiGet<GlobalConfig>("/config"),
])
.then(([sts, cfg]) => {
if (!alive) return;
setStrategies(sts);
setConfig(cfg);
})
.catch((e: Error) => alive && setError(e.message));
// 组合库单独拉取:即便它失败,也不该连累策略列表与公共配置(否则整页只剩一条错误)。
apiGet<BacktestCombo[]>("/combos")
.then((cbs) => {
if (alive) setCombos(cbs);
})
.catch((e: Error) => {
if (!alive) return;
setError(e.message);
// 给空列表,否则「组合库」会永远停在加载中
setCombos((prev) => prev ?? []);
});
return () => {
alive = false;
};
}, []);
const loadCombos = useCallback(async () => {
try {
setCombos(await apiGet<BacktestCombo[]>("/combos"));
} catch (e) {
setError((e as Error).message);
setCombos([]);
}
}, []);
useEffect(() => {
if (bootstrapped.current) return;
bootstrapped.current = true;
(async () => {
if (comboId) {
try {
const c = await apiGet<BacktestCombo>(`/combos/${encodeURIComponent(comboId)}`);
setDraft(comboToDraft(c));
setSavedComboId(c.id ?? null);
setNotice(`已载入回测组合「${c.name}」(${c.id})`);
return;
} catch (e) {
setError(`回测组合 ${comboId} 载入失败:${(e as Error).message}`);
}
}
if (presetStrategy) {
setDraft((d) => ({ ...d, strategyIds: [presetStrategy] }));
setNotice(`已把选股策略 ${presetStrategy} 预勾选进组合 —— 可再加其它策略、填好参数后运行。`);
}
})();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const errors = validateDraft(draft, strategies);
const blocking = Object.keys(errors);
function toggleStrategy(id: string) {
setDraft((d) => ({
...d,
strategyIds: d.strategyIds.includes(id)
? d.strategyIds.filter((x) => x !== id)
: [...d.strategyIds, id],
}));
}
async function run() {
if (blocking.length) {
setError(errors[blocking[0]]);
setRevealErrors(true);
requestAnimationFrame(() => {
const el = document.querySelector<HTMLElement>(".input--invalid, .field--invalid");
el?.scrollIntoView({ block: "center", behavior: "smooth" });
});
return;
}
await runVia(() => submitComboJob(draftToCombo(draft)), null);
}
/**
* 共用的「提交 Job → 轮询 → 出结果」管道。
*
* 两条入口共用:① 用页面上的草稿运行(临时组合,不落库);② 从组合库直接运行已保存组合
* (走 POST /combos/{id}/run,跑库里那份参数)。抽出来是为了让两条路径的阶段显示、
* 归档提示、错误处理完全一致,不会一条修了另一条忘了。
*/
async function runVia(submit: () => Promise<{ job_id: string }>, comboId: string | null) {
setRunning(true);
setRunningComboId(comboId);
setError("");
setNotice("");
setJobId("");
setResult(null);
try {
const { job_id } = await submit();
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;
setNotice(exp ? `回测完成,已归档为实验 ${exp}(可在「实验对比」页与其它版本对比)。` : "回测完成,已自动归档。");
} else {
setError(`任务${out.status}${out.error ? `:${out.error}` : ""}`);
}
} catch (e) {
setError((e as Error).message);
} finally {
setRunning(false);
setRunningComboId(null);
setJobId("");
}
}
/* ---------- 组合库操作 ---------- */
/** 把库里的一份组合回填到表单(不自动运行,先把参数摆出来给用户看/改)。 */
function loadCombo(c: BacktestCombo) {
setDraft(comboToDraft(c));
setSavedComboId(c.id ?? null);
setError("");
setRevealErrors(false);
setNotice(`已载入回测组合「${c.name}」(${c.id})—— 可直接运行,或改完参数再「更新组合」。`);
}
/** 用库里的参数直接运行(不掺入表单里未保存的改动)。 */
async function runSaved(c: BacktestCombo) {
if (!c.id) return;
setDraft(comboToDraft(c));
setSavedComboId(c.id);
await runVia(() => runSavedCombo(c.id as string), c.id);
}
/** 清空表单,开始一个新组合(不影响库里已有的)。 */
function startNewCombo() {
setDraft(emptyDraft(range));
setSavedComboId(null);
setResult(null);
setNotice("已清空表单,开始一个新组合。");
setError("");
setRevealErrors(false);
}
async function removeCombo(c: BacktestCombo) {
if (!c.id) return;
if (!window.confirm(`删除回测组合「${c.name}」(${c.id})?此操作不可撤销(归档结果不受影响)。`)) {
return;
}
setDeletingComboId(c.id);
setError("");
try {
await apiDelete(`/combos/${encodeURIComponent(c.id)}`);
if (savedComboId === c.id) setSavedComboId(null);
setNotice(`已删除回测组合「${c.name}」。`);
await loadCombos();
} catch (e) {
setError((e as Error).message);
} finally {
setDeletingComboId(null);
}
}
async function saveCombo() {
if (blocking.length) {
setError(errors[blocking[0]]);
setRevealErrors(true);
return;
}
if (!draft.name.trim()) {
setError("保存组合需要起个名字");
setRevealErrors(true);
return;
}
setSaving(true);
setError("");
try {
const combo = draftToCombo(draft);
if (savedComboId) {
await apiPut<BacktestCombo>(`/combos/${encodeURIComponent(savedComboId)}`, combo);
setNotice(`已更新回测组合「${combo.name}」`);
} else {
const saved = await apiPost<BacktestCombo>("/combos", combo);
setSavedComboId(saved.id ?? null);
setNotice(`已保存回测组合「${saved.name}」(${saved.id})`);
}
await loadCombos();
} catch (e) {
setError((e as Error).message);
} finally {
setSaving(false);
}
}
const showErr = (k: string) => (revealErrors ? errors[k] : undefined);
return (
<>
<PageHeader
title="回测组合"
sub="选一个或多个选股策略,填上回测参数(资金/持仓/持仓天数区间/调仓时机/区间),保存为组合或直接运行;已保存的组合在下方「组合库」里可载入、直接运行、删除。费率与复权口径取自公共配置。"
actions={
<div className="row" style={{ gap: 8 }}>
<Link href="/strategies" className="btn"><span>选股策略库</span></Link>
<Link href="/settings" className="btn"><span>公共配置</span></Link>
<Link href="/experiments" className="btn"><span>实验对比</span></Link>
</div>
}
/>
{error ? <Banner tone="error">{error}</Banner> : null}
{notice ? <Banner tone="info">{notice}</Banner> : null}
{/* 公共配置(只读) */}
{config ? (
<Card icon="database" title="本次回测采用的公共配置(只读)" tools={
<Link href="/settings" className="btn btn--sm"><span>去修改</span></Link>
}>
<div className="chips">
<span className="chip">佣金 <b>{(config.commission_rate * 100).toFixed(3)}%</b></span>
<span className="chip">印花税 <b>{(config.stamp_tax_rate * 100).toFixed(2)}%</b></span>
<span className="chip">滑点 <b>{(config.slippage_rate * 100).toFixed(2)}%</b></span>
<span className="chip">最低佣金 <b>{config.min_commission} 元</b></span>
<span className="chip">复权 <b>{adjustLabel(config.price_adjustment)}</b></span>
<span className="chip">基准 <b className="mono">{config.benchmark}</b></span>
</div>
<div className="hint" style={{ marginTop: 8 }}>
运行时这些值会被**快照**进归档,事后改公共配置不影响这次结果的数字。
</div>
</Card>
) : null}
{/* 组合库:保存过的组合在这里能被找回(否则「保存」等于存进黑洞) */}
<Card
icon="archive"
title={`已保存的回测组合${combos ? ` · ${combos.length}` : ""}`}
tools={
<div className="row" style={{ gap: 8 }}>
<Btn size="sm" onClick={startNewCombo} disabled={running}>
新建组合
</Btn>
<Btn size="sm" onClick={() => void loadCombos()}>
刷新
</Btn>
</div>
}
>
{combos === null ? (
<Loading label="读取已保存的回测组合…" />
) : combos.length === 0 ? (
<Empty
icon="archive"
title="还没有保存过回测组合"
hint="勾选策略、填好参数后点「保存为回测组合」,之后就能在这里一键载入或直接运行。"
/>
) : (
<div className="combo-list">
{combos.map((c) => {
const isCurrent = savedComboId === c.id;
const isRunning = runningComboId === c.id;
return (
<div key={c.id ?? c.name} className={`combo-item${isCurrent ? " is-on" : ""}`}>
<div className="combo-item__main">
<div className="row" style={{ gap: 8, flexWrap: "wrap", alignItems: "center" }}>
<b>{c.name}</b>
{isCurrent ? (
<Pill tone="violet" icon="check">当前载入</Pill>
) : null}
{isRunning ? <Pill tone="warn">运行中…</Pill> : null}
<span className="mono hint">{c.id}</span>
</div>
<div className="chips" style={{ marginTop: 6 }}>
<span className="chip">持仓数 <b>{c.hold_count}</b></span>
<span className="chip">
持仓区间 <b>[{c.hold_min_days}, {c.hold_max_days ?? "∞"}] 天</b>
</span>
<span className="chip">调仓 <b>{FREQ_LABEL[c.rebalance_freq] ?? c.rebalance_freq}</b></span>
<span className="chip">区间 <b>{c.period[0]} ~ {c.period[1]}</b></span>
<span className="chip">引用策略 <b>{c.strategy_ids.length}</b> 个</span>
</div>
{c.description ? <div className="strategy-desc">{c.description}</div> : null}
</div>
<div className="strategy-actions">
<Btn size="sm" icon="edit" disabled={running} onClick={() => loadCombo(c)}>
载入到表单
</Btn>
<Btn
size="sm"
variant="primary"
icon="play"
loading={isRunning}
disabled={running}
onClick={() => void runSaved(c)}
>
直接运行
</Btn>
<Btn
size="sm"
variant="danger"
icon="trash"
loading={deletingComboId === c.id}
disabled={running}
onClick={() => void removeCombo(c)}
>
删除
</Btn>
</div>
</div>
);
})}
</div>
)}
<div className="hint" style={{ marginTop: 8 }}>
「直接运行」用库里保存的那份参数(不受表单里未保存改动影响);「载入到表单」把参数摆出来,
改完点上方「更新组合」覆盖,或直接点「运行回测组合」按当前草稿跑。
</div>
</Card>
{/* 选股策略多选 */}
<Card icon="book" title={`选择选股策略(已选 ${draft.strategyIds.length})`} tools={
<span className="hint">多策略取并集后按 Borda 秩和统一打分排序</span>
}>
{strategies.length === 0 ? (
<Empty icon="book" title="还没有选股策略" hint={
<Link href="/strategies">先去策略库建一个「怎么选」的策略</Link>
} />
) : (
<div className="strategy-pick">
{strategies.map((s) => {
const on = draft.strategyIds.includes(s.id ?? "");
return (
<label key={s.id} className={`strategy-pick__item${on ? " is-on" : ""}`}>
<input
type="checkbox"
checked={on}
onChange={() => s.id && toggleStrategy(s.id)}
aria-label={`选择选股策略 ${s.name}`}
/>
<span className="strategy-pick__name">{s.name}</span>
<span className="strategy-pick__meta mono">{s.id}</span>
<span className="strategy-pick__desc">{s.description || "(无说明)"}</span>
<span className="strategy-pick__factors">
{s.factors?.map((f) => f.name).join(" + ") || "—"}
</span>
</label>
);
})}
</div>
)}
{showErr("strategies") && <div className="field-err" role="alert">{showErr("strategies")}</div>}
</Card>
{/* 回测参数 */}
<Card
icon="gauge"
title={savedComboId ? `回测参数 · 正在编辑组合 ${savedComboId}` : "回测参数 · 新组合"}
tools={
<div className="row" style={{ gap: 8 }}>
<Btn icon="book" loading={saving} disabled={running || saving} onClick={saveCombo}>
{savedComboId ? "更新组合" : "保存为回测组合"}
</Btn>
</div>
}
>
<div className="params-form">
<div className="form-grid">
<Field label="组合名称(保存时必填)" hint="便于在组合列表里识别">
<input
className={`input${showErr("name") ? " input--invalid" : ""}`}
value={draft.name}
maxLength={64}
placeholder="例:高股息双策略 · 月频"
disabled={running}
onChange={(e) => setDraft({ ...draft, name: e.target.value })}
/>
</Field>
<Field
label="组合说明(可选)"
hint={`记下这个组合的用意(${draft.description.length}/300)`}
className="field--wide"
>
<input
className="input"
value={draft.description}
maxLength={300}
placeholder="例:用两个高股息选股策略取并集,月频调仓,验证 Tmax=30 天强制了结的影响"
disabled={running}
onChange={(e) => setDraft({ ...draft, description: e.target.value })}
/>
</Field>
<Field label="起始资金(元)" hint="回测初始本金,默认 100 万">
<input
className={`input mono${showErr("capital") ? " input--invalid" : ""}`}
type="number" inputMode="numeric" step={100000} min={10000}
value={draft.initialCapital} disabled={running}
onChange={(e) => setDraft({ ...draft, initialCapital: Number(e.target.value) })}
/>
</Field>
<Field label="持仓数 N" hint="目标等权持有的股票只数">
<input
className={`input mono${showErr("holdCount") ? " input--invalid" : ""}`}
type="number" inputMode="numeric" min={1} max={1000}
value={draft.holdCount} disabled={running}
onChange={(e) => setDraft({ ...draft, holdCount: Number(e.target.value) })}
/>
</Field>
<Field label="调仓时机" hint="多久重新打分并调仓一次">
<select
className="input"
value={draft.rebalanceFreq} disabled={running}
onChange={(e) => setDraft({ ...draft, rebalanceFreq: e.target.value as Draft["rebalanceFreq"] })}
>
<option value="daily">每日</option>
<option value="weekly">每周</option>
<option value="monthly">每月</option>
</select>
</Field>
<Field label="最短持仓 Tmin(天)" hint="掉出 TopN 时未满此天数暂不卖(防频繁换手);0=不保护">
<input
className={`input mono${showErr("holdMinDays") ? " input--invalid" : ""}`}
type="number" inputMode="numeric" min={0}
value={draft.holdMinDays} disabled={running}
onChange={(e) => setDraft({ ...draft, holdMinDays: Number(e.target.value) })}
/>
</Field>
<Field label="最长持仓 Tmax(天)" hint="超过即强制了结(每个交易日检查);留空=不限">
<div className="row" style={{ gap: 8, alignItems: "center" }}>
<input
className={`input mono${showErr("holdMaxDays") ? " input--invalid" : ""}`}
type="number" inputMode="numeric" min={1}
value={draft.holdMaxDays ?? ""} disabled={running}
placeholder="不限"
onChange={(e) => setDraft({ ...draft, holdMaxDays: e.target.value === "" ? null : Number(e.target.value) })}
/>
<label className="check" style={{ whiteSpace: "nowrap" }}>
<input
type="checkbox"
checked={draft.holdMaxDays === null}
disabled={running}
onChange={(e) => setDraft({ ...draft, holdMaxDays: e.target.checked ? null : 30 })}
/>
不限
</label>
</div>
</Field>
<Field label="开始日期" className={showErr("period") ? "field--invalid" : undefined}>
<input type="date" className="input" value={draft.start} disabled={running}
onChange={(e) => setDraft({ ...draft, start: e.target.value })} />
</Field>
<Field label="结束日期" className={showErr("period") ? "field--invalid" : undefined}>
<input type="date" className="input" value={draft.end} disabled={running}
onChange={(e) => setDraft({ ...draft, end: e.target.value })} />
</Field>
</div>
{showErr("period") && <div className="field-err" role="alert">{showErr("period")}</div>}
<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={draft.holdCount} · [{draft.holdMinDays}, {draft.holdMaxDays ?? "∞"}] 天 ·{" "}
{draft.rebalanceFreq === "daily" ? "日频" : draft.rebalanceFreq === "weekly" ? "周频" : "月频"} ·{" "}
{draft.start} ~ {draft.end} · {draft.strategyIds.length} 个策略
</span>
{blocking.length ? (
<Pill tone="warn" icon="alert">有 {blocking.length} 处需要修正</Pill>
) : (
<Pill tone="pos" icon="check">参数校验通过</Pill>
)}
</div>
</div>
{running ? (
<div className="stages" style={{ marginTop: 12 }}>
{["data_loading", "backtesting", "analysis"].map((st) => {
const order = ["queued", "data_loading", "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
(全市场多年区间约 3~5 分钟,可离开本页,结果会归档)
</span>
</div>
) : null}
</Card>
{!result && !running && !error ? (
<Card>
<Empty icon="gauge" title="尚未运行回测"
hint="先勾选至少一个选股策略、填好回测参数,再点「运行回测组合」。可先「保存为回测组合」以便复用。" />
</Card>
) : null}
{result ? <BacktestResultView result={result} name={draft.name || "回测组合"} /> : null}
</>
);
}