Files
qlib/frontend/web/app/backtest/page.tsx
T
Simon 48a97c2a12 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 渲染,截图确认表格与曲线数值正确。
2026-10-01 17:57:00 +08:00

667 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);
// 本次结果的归档 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("");
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);
setResultArchiveId(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;
setResultArchiveId(exp);
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 || "回测组合"}
archive={resultArchiveId ? { id: resultArchiveId } : undefined}
/>
) : null}
</>
);
}