diff --git a/docs/USAGE.md b/docs/USAGE.md
index a7e48bf..289b647 100644
--- a/docs/USAGE.md
+++ b/docs/USAGE.md
@@ -534,6 +534,13 @@ PYTHONPATH=. .venv/bin/python -m app.cli.restore_experiment_from_job \
> 失败显示后端原文、成功给「打开归档 / 去对比」;反馈条出现时自动滚入视野
> (运行按钮常在长表单底部,不滚过去等于没显示),并带 `role="status" aria-live="polite"`。
> 各页多个作业互不干扰(各自 `anchorId`)。
+> 阶段小圆点**按作业类型分别定义**(`STAGE_PIPELINES`):因子测试是
+> 「加载行情与因子数据 → 计算因子值 → 汇总指标与曲线」,回测/回测组合是
+> 「加载行情与因子数据 → 撮合与净值结算 → 汇总指标与曲线」,异步选股只有
+> 「逐择股日选股」一段 —— 后端没上报的阶段不会画成「○ 待开始」(那是看似有数据的假象),
+> 后端新阶段的文案没跟上时也不会露出裸英文枚举(显示「执行中(stage)」并保留已推进的圆点)。
+> `POST /api/signals` 是**同步**接口,页面如实写明「同步请求、没有作业号与阶段、不能取消」,
+> 而不是给它编一个作业号。
**图表**:全站统一使用 **TradingView Lightweight Charts**(`components/charts/LwChart.tsx`),
ECharts 已从依赖中移除。买卖点标记(▲绿=买入 / ▼红=卖出)只落在该 series 真实存在的交易日上,
diff --git a/frontend/web/app/backtest/page.tsx b/frontend/web/app/backtest/page.tsx
index 8c1edb9..60cec5e 100644
--- a/frontend/web/app/backtest/page.tsx
+++ b/frontend/web/app/backtest/page.tsx
@@ -646,12 +646,15 @@ function BacktestInner() {
- {/* 反馈条紧贴运行按钮:点了就有字,且在排队阶段就能取消 */}
+ {/* 反馈条紧贴运行按钮:点了就有字,且在排队阶段就能取消。
+ kind="backtest":本页跑的是 combo 作业,但后端上报的阶段与 backtest 完全一致
+ (combo_service.py:68/70/90),所以共用同一条阶段序列。 */}
{job.state.phase === "idle" && running ? (
diff --git a/frontend/web/app/factors/compose/page.tsx b/frontend/web/app/factors/compose/page.tsx
index fbf582f..d7701b1 100644
--- a/frontend/web/app/factors/compose/page.tsx
+++ b/frontend/web/app/factors/compose/page.tsx
@@ -2,7 +2,7 @@
import { useEffect, useState } from "react";
import { apiGet } from "@/lib/api";
-import { submitJob, waitJob } from "@/lib/jobs";
+import { submitJob, useJobRunner } from "@/lib/jobs";
import { factorLabel, pickableFactors } from "@/lib/factors";
import type { BacktestResult, FactorMeta, ResearchSpec } from "@/lib/types";
import { recentRange } from "@/lib/dates";
@@ -13,10 +13,10 @@ import {
Field,
Btn,
Banner,
- Progress,
Signed,
} from "@/components/ui";
import { BacktestResultView } from "@/components/BacktestResultView";
+import { JobProgress } from "@/components/JobProgress";
import { Icon } from "@/components/icons";
interface Pick {
@@ -35,8 +35,16 @@ export default function ComposePage() {
const [result, setResult] = useState(null);
/** 本次结果的归档 id:结果区的「新页面放大」从归档读同一份数据 */
const [archiveId, setArchiveId] = useState(null);
- const [running, setRunning] = useState(false);
- const [jobId, setJobId] = useState("");
+ /**
+ * 作业反馈统一交给 `useJobRunner`(与 /backtest 同一套):点了就有「正在提交作业…」,
+ * 之后是后端真实阶段 + 每秒自增的已用时间 + 作业号 + 可取消。
+ * 此前这里只有一句「任务 JOB-xxx 后台执行中」的假进度条(value 固定 30),
+ * 用户既不知道提交成功没、也不知道跑到哪一步。
+ */
+ const job = useJobRunner("因子组合回测");
+ const active =
+ job.state.phase === "submitting" || job.state.phase === "queued" || job.state.phase === "running";
+ /** 只留「参数不合法 / 目录加载失败」这类**作业之外**的错误;作业自身的失败由反馈条显示后端原文 */
const [error, setError] = useState("");
useEffect(() => {
@@ -82,35 +90,27 @@ export default function ComposePage() {
setError("请选择至少一个因子并设置大于 0 的权重");
return;
}
- setRunning(true);
setError("");
- setJobId("");
setResult(null);
setArchiveId(null); // 新一次运行:先清掉上一次的归档 id,避免放大到旧结果
- try {
- const spec: ResearchSpec = {
- type: "backtest",
- universe: { exclude_st: excludeSt, min_listing_days: 0 },
- factors: valid.map((p) => ({ name: p.name, weight: p.weight })),
- selection: { top_n: topN },
- rebalance,
- period: [start, end],
- };
- const { job_id } = await submitJob(spec);
- setJobId(job_id);
- const out = await waitJob(job_id);
- if (out.status === "success" && out.result) {
- setResult(out.result);
- setArchiveId(out.experimentId ?? null);
- } else {
- setError(`任务${out.status}${out.error ? `:${out.error}` : ""}`);
- }
- } catch (e) {
- setError((e as Error).message);
- } finally {
- setRunning(false);
- setJobId("");
+ const spec: ResearchSpec = {
+ type: "backtest",
+ universe: { exclude_st: excludeSt, min_listing_days: 0 },
+ factors: valid.map((p) => ({ name: p.name, weight: p.weight })),
+ selection: { top_n: topN },
+ rebalance,
+ period: [start, end],
+ };
+ const out = await job.run(() => submitJob(spec), {
+ label: `因子组合回测(${valid.length} 个因子)`,
+ });
+ if (out.status === "success" && out.result) {
+ setResult(out.result);
+ // 归档 id 交给结果区做「新页面放大」;反馈条自己也会给「打开归档 / 去对比」
+ setArchiveId(out.experimentId ?? null);
}
+ // 失败 / 超时 / 取消都只在反馈条上说:后端原文(out.error)、
+ // 「已取消(不产生归档)」都在 JobProgress 里,页面不再另写一份 Banner 重复同一个失败
}
return (
@@ -266,26 +266,22 @@ export default function ComposePage() {
- {running ? "后台运行中…" : `回测组合(${picks.length} 个因子)`}
+ {active ? "后台运行中…" : `回测组合(${picks.length} 个因子)`}
- {running ? (
-
-
- ) : null}
+ {/* 反馈条紧贴运行按钮:提交瞬间就有字,排队阶段就能取消,完成后给归档入口 */}
+
{error ? {error}
: null}
diff --git a/frontend/web/app/factors/page.tsx b/frontend/web/app/factors/page.tsx
index 74beeb2..7d00ed9 100644
--- a/frontend/web/app/factors/page.tsx
+++ b/frontend/web/app/factors/page.tsx
@@ -2,7 +2,7 @@
import { useCallback, useEffect, useState } from "react";
import { apiGet, apiPatch, apiPost } from "@/lib/api";
-import { submitJob, waitJob } from "@/lib/jobs";
+import { submitJob, useJobRunner } from "@/lib/jobs";
import type {
FactorMeta,
FactorParam,
@@ -19,10 +19,10 @@ import {
Field,
Btn,
Banner,
- Progress,
Empty,
SkeletonLines,
} from "@/components/ui";
+import { JobProgress } from "@/components/JobProgress";
/**
* 因子研究页。
@@ -180,10 +180,15 @@ export default function FactorsPage() {
const [start, setStart] = useState("");
const [end, setEnd] = useState("");
const [reports, setReports] = useState>({});
- const [running, setRunning] = useState(false);
- const [processed, setProcessed] = useState(0);
- const [current, setCurrent] = useState<{ idx: number; total: number; name: string } | null>(null);
- const [jobId, setJobId] = useState("");
+ /**
+ * 作业反馈统一交给 `useJobRunner`:本页是「一个因子一个 Job」的批量场景,
+ * 每轮把 (第 i/共 n 个) 作为 progress 交给反馈条 —— 阶段、作业号、已用秒数全部来自后端,
+ * 不再由前端按 processed/selected 算一个百分比假进度条(用户看不出到底跑到哪了)。
+ */
+ const job = useJobRunner("单因子测试");
+ /** 反馈条是否处于「跑着」的状态:用于禁用按钮、让位给反馈条的提示位。 */
+ const active =
+ job.state.phase === "submitting" || job.state.phase === "queued" || job.state.phase === "running";
const [error, setError] = useState("");
/** 目录级成功提示(停用 / 启用)。 */
const [notice, setNotice] = useState("");
@@ -319,47 +324,41 @@ export default function FactorsPage() {
setError("请至少选择一个因子");
return;
}
- setRunning(true);
setError("");
- setJobId("");
setReports({});
- setProcessed(0);
- try {
- let index = 0;
- for (const name of names) {
- index += 1;
- setCurrent({ idx: index, total: names.length, name });
- const spec: ResearchSpec = {
- type: "factor_test",
- universe: { exclude_st: true, min_listing_days: 0 },
- factors: [{ name, weight: 1 }],
- selection: { top_n: 10 },
- rebalance: "monthly",
- period: [start, end],
- };
- const { job_id } = await submitJob(spec);
- setJobId(job_id);
- const out = await waitJob(job_id);
- if (out.status === "success" && out.result) {
- setReports((prev) => ({ ...prev, [name]: out.result! }));
- } else {
- const msg = `因子 ${name}:${out.status}${out.error ? `:${out.error}` : ""}`;
- setError((prev) => (prev ? `${prev}\n${msg}` : msg));
- }
- setProcessed(index);
+ // 多因子是**一个因子一个 Job**(后端逐因子归档),所以这里顺序提交、逐个 await;
+ // 每轮都把进度交给统一反馈条:点了第一个就有「正在提交作业…」+ 作业号,不必等全部跑完。
+ let index = 0;
+ for (const name of names) {
+ index += 1;
+ const spec: ResearchSpec = {
+ type: "factor_test",
+ universe: { exclude_st: true, min_listing_days: 0 },
+ factors: [{ name, weight: 1 }],
+ selection: { top_n: 10 },
+ rebalance: "monthly",
+ period: [start, end],
+ };
+ const out = await job.run(() => submitJob(spec), {
+ label: `单因子测试 · ${name}`,
+ progress: { index, total: names.length },
+ });
+ if (out.status === "success" && out.result) {
+ setReports((prev) => ({ ...prev, [name]: out.result! }));
+ } else if (out.status === "cancelled") {
+ // 取消是用户的明确意图,不是错误:已经跑完的报告留在页面上,后面的因子不再跑
+ break;
+ } else {
+ // 单个因子失败不该吞掉其它因子的报告:把后端原文累积成一份批级错误清单,
+ // 逐个跑(而不是整批放弃)也和老行为一致
+ const msg = `因子 ${name}:${out.status}${out.error ? `:${out.error}` : ""}`;
+ setError((prev) => (prev ? `${prev}\n${msg}` : msg));
}
- } catch (e) {
- setError((e as Error).message);
- } finally {
- setRunning(false);
- setCurrent(null);
- setJobId("");
}
}
const selectedCount = checked.size;
const reportNames = Object.keys(reports);
- const progressPct = selectedCount === 0 ? 0 : Math.round((processed / selectedCount) * 100);
const tpl = templates.find((x) => x.name === tplName) ?? null;
const tplSpecs = tpl?.param_specs ?? [];
@@ -372,12 +371,12 @@ export default function FactorsPage() {
actions={{reportNames.length}/{selectedCount} 个已出报告}
/>
- {error && !running ? (
+ {error && !active ? (
{error}
) : null}
- {notice && !running ? {notice} : null}
+ {notice && !active ? {notice} : null}
- 执行中 : undefined}>
+ 执行中 : undefined}>
setStart(e.target.value)} />
@@ -590,34 +589,30 @@ export default function FactorsPage() {
- {running
- ? `运行中 ${processed}/${selectedCount}`
+ {/* 按钮上只保留「跑第几个」这一条信息;秒表 / 阶段 / 作业号都在反馈条上,不重复 */}
+ {active
+ ? `运行中 ${job.state.progress?.index ?? 0}/${job.state.progress?.total ?? selectedCount}`
: selectedCount === 0
? "请先勾选因子"
: `运行因子测试(${selectedCount} 个)`}
- {running ? (
-
-
- ) : null}
+ {/* 反馈条:点了立刻有字(正在提交作业…),随后是真实阶段 + 每秒自增的已用时间 + 可取消 */}
+
- {reportNames.length === 0 && !running && !error ? (
+ {reportNames.length === 0 && !active && !error ? (
会让接收端 GET /api/selections/{id} 404 → 坏链接。
*/
const [savedSelectionId, setSavedSelectionId] = useState("");
+ /**
+ * 同步选股(POST /selections)正在执行 —— 这条路径是**同步接口**,没有 job_id,
+ * 所以不能套 useJobRunner(它要轮询 /jobs/{id})。保留 running 只用于这条路径的按钮态。
+ */
const [running, setRunning] = useState(false);
- const [asyncBusy, setAsyncBusy] = useState(false);
+ /**
+ * 异步选股(POST /selections/jobs)的作业反馈统一交给 `useJobRunner`:
+ * 提交瞬间就有「正在提交作业…」,之后是真实阶段 + 每秒自增的已用时间 + 作业号 + 可取消。
+ * 此前只有一个 asyncBusy 布尔值,用户点了「异步(全市场)」只看到按钮变灰,
+ * 不知道到底提交成功没、作业号是多少(正是「点了没反应」的投诉点)。
+ */
+ const job = useJobRunner("异步选股(全市场)");
+ /** 异步作业是否在跑:既用于禁用两个按钮,也用于切换异步按钮的文案。 */
+ const asyncActive =
+ job.state.phase === "submitting" || job.state.phase === "queued" || job.state.phase === "running";
+ /** 只留「同步选股失败 / 历史读取失败」这类**作业之外**的错误;异步作业的失败由反馈条显示后端原文 */
const [error, setError] = useState("");
const [history, setHistory] = useState(null);
@@ -146,26 +161,24 @@ export default function SelectionPage() {
/** 异步(全市场等长任务):提交 Job 后台执行并轮询(解决同步 60s+)。 */
async function runAsync() {
- setAsyncBusy(true);
setError("");
setResult(null);
// 同上:异步任务不落库选股记录,直通回测必须隐藏按钮(不给坏链接)
setSavedSelectionId("");
- try {
- const { job_id } = await apiPost<{ job_id: string }>("/selections/jobs", buildQuery());
- const out = await waitJob(job_id);
- if (out.status === "success" && out.result) {
- setSelectionId(job_id);
- setResult(out.result);
- apiGet("/selections?limit=8").then(setHistory).catch(() => undefined);
- } else {
- setError(`任务${out.status}${out.error ? `:${out.error}` : ""}`);
- }
- } catch (e) {
- setError((e as Error).message);
- } finally {
- setAsyncBusy(false);
+ // 提交闭包把真实 job_id 记下来:页头那个 Pill 沿用老行为显示本次运行的 id,
+ // 而 job.state.jobId 在 await 之后读到的仍是本轮渲染的旧值(React 状态不会在同一 tick 内更新)
+ let submittedId = "";
+ const out = await job.run(async () => {
+ const r = await apiPost<{ job_id: string }>("/selections/jobs", buildQuery());
+ submittedId = r.job_id;
+ return r;
+ });
+ if (out.status === "success" && out.result) {
+ setSelectionId(submittedId);
+ setResult(out.result);
+ apiGet("/selections?limit=8").then(setHistory).catch(() => undefined);
}
+ // 失败 / 超时 / 取消:反馈条已给出后端原文或「已取消(不产生归档)」,不在这里重复报错
}
async function openHistory(id: string) {
@@ -378,13 +391,21 @@ export default function SelectionPage() {
)}
-
+
{running ? "筛选中…" : "执行选股"}
-
- {asyncBusy ? "后台执行中…" : "异步(全市场)"}
+
+ {asyncActive ? "后台执行中…" : "异步(全市场)"}
+ {/* 异步作业的反馈条:点了立刻有字 + 真实阶段 + 可取消(同步按钮不走这里) */}
+
{error ? (
{error}
diff --git a/frontend/web/app/signals/page.tsx b/frontend/web/app/signals/page.tsx
index df5eec7..bb81c14 100644
--- a/frontend/web/app/signals/page.tsx
+++ b/frontend/web/app/signals/page.tsx
@@ -138,6 +138,21 @@ export default function SignalsPage() {
{running ? "生成中…" : "生成信号"}
+
+ {/*
+ 为什么这里没有 JobProgress:`POST /signals` 是**同步**接口(backend/app/api/signals.py
+ 只有 POST "",没有 /signals/jobs),拿不到 job_id,也就无从轮询阶段 —— 套 useJobRunner
+ 会去 GET /jobs/{signal_id} 撞 404,而合成一个假的 JobRunState 更是「看似有数据的假象」。
+ 所以这里如实说清三件事:同步连接、没有作业号/阶段/取消、出错时显示后端原文。
+ */}
+ {running ? (
+
+ 同步请求已发出:请求期间浏览器一直连着后端(这条路径不是后台作业,不能关页离开)。
+ 因为是同步接口,没有作业号与执行阶段,也没有取消按钮。
+ 该请求通常在数秒内返回;若后端报错或连接中断,下方会显示后端原文。
+
+ ) : null}
+
{error ? {error}
: null}
diff --git a/frontend/web/components/JobProgress.tsx b/frontend/web/components/JobProgress.tsx
index db9fde4..cc0b151 100644
--- a/frontend/web/components/JobProgress.tsx
+++ b/frontend/web/components/JobProgress.tsx
@@ -13,12 +13,12 @@
* 不滚一下用户就看不见(这正是「没有反馈」错觉的来源之一)。
*/
-import { useEffect, useRef } from "react";
+import { useEffect, useRef, useState } from "react";
import Link from "next/link";
import { Btn, Pill } from "@/components/ui";
import { Icon } from "@/components/icons";
-import { STAGE_LABEL, STAGE_ORDER, type JobRunState } from "@/lib/jobs";
+import { STAGE_LABEL, STAGE_PIPELINES, type JobKind, type JobRunState } from "@/lib/jobs";
/** 已用时间:< 60s 显示秒,否则 mm:ss(跑几分钟时更好读) */
function fmtElapsed(ms: number): string {
@@ -38,15 +38,36 @@ export function JobProgress({
onDismiss,
/** 完成后是否自动滚到视野(提交时恒滚,完成后不打断阅读) */
anchorId = "job-progress",
+ /** 作业类型:决定阶段小圆点画哪几条 —— 因子测试没有「逐择股日选股 / 撮合与净值结算」,
+ * 选股也只有一个真实阶段,全站共用一张表会把这些不存在的阶段画成「○ 待开始」。 */
+ kind = "backtest",
}: {
state: JobRunState;
onCancel?: () => void;
onDismiss?: () => void;
anchorId?: string;
+ kind?: JobKind;
}) {
const ref = useRef(null);
const scrolled = useRef(false);
const active = state.phase === "submitting" || state.phase === "queued" || state.phase === "running";
+ /** 本类作业真实会走的阶段(不含 queued / done —— 那是作业状态,不是后端阶段) */
+ const pipeline = STAGE_PIPELINES[kind];
+ /**
+ * 后端阶段是前端还不认识的枚举时(后端先加、前端文案没跟上),
+ * 保留**上一个已知阶段**的进度而不是把整排圆点打成灰(那看起来像「什么都没在跑」);
+ * 原始枚举另在 Pill 上兜底显示(如「执行中(factor_calculation)」),不出现裸英文。
+ */
+ const [lastKnown, setLastKnown] = useState("");
+ useEffect(() => {
+ // 新一轮提交(submitting)先清空:否则同一页第二次运行时,会拿上一轮的最后阶段当进度,
+ // 新作业明明刚排队却显示「已完成到分析的 ✓」——那是假的进度。
+ if (state.phase === "submitting") {
+ setLastKnown("");
+ return;
+ }
+ if (pipeline.includes(state.stage)) setLastKnown(state.stage);
+ }, [state.phase, state.stage, pipeline]);
// 提交瞬间(含同一页面第二次提交)把反馈条滚进视野:按钮在长表单底部时,
// 顶部出现的反馈条不滚过去就等于没显示。
@@ -66,9 +87,9 @@ export function JobProgress({
const stageText = state.stage
? state.stage === "cancelling"
? "正在取消…"
- : (STAGE_LABEL[state.stage] ?? state.stage)
+ : (STAGE_LABEL[state.stage] ?? `执行中(${state.stage})`)
: "等待后端回报阶段";
- const cur = STAGE_ORDER.indexOf(state.stage);
+ const cur = pipeline.indexOf(pipeline.includes(state.stage) ? state.stage : lastKnown);
const archiveHref = state.experimentId
? `/experiments?exp=${encodeURIComponent(state.experimentId)}`
: null;
@@ -158,8 +179,8 @@ export function JobProgress({
{active ? (
- {["data_loading", "selection", "backtesting", "analysis"].map((st) => {
- const mine = STAGE_ORDER.indexOf(st);
+ {pipeline.map((st) => {
+ const mine = pipeline.indexOf(st);
const cls = cur >= 0 && mine < cur ? "stage is-done" : mine === cur ? "stage is-active" : "stage";
return (
diff --git a/frontend/web/lib/jobs.ts b/frontend/web/lib/jobs.ts
index 8ed4b4f..eef6b93 100644
--- a/frontend/web/lib/jobs.ts
+++ b/frontend/web/lib/jobs.ts
@@ -67,18 +67,54 @@ export async function waitJob(
return { status: "timeout", result: null, error: "等待结果超时,请稍后在「实验」页查看归档" };
}
-/** 阶段中文名(后端 stage 枚举 → 用户可读) */
+/** 阶段中文名(后端 stage 枚举 → 用户可读)。
+ *
+ * 必须覆盖每种作业**上报过的全部阶段**:漏一个,JobProgress 只能把裸枚举摆给用户
+ * —— 实测漏过 `factor_calculation`(/factors 反馈条上直接出现过英文 `factor_calculation`)。
+ * 新增阶段时请同时补这里的中文名与下方 `STAGE_PIPELINES` 里对应的序列。
+ */
export const STAGE_LABEL: Record = {
queued: "排队中",
data_loading: "加载行情与因子数据",
+ factor_calculation: "计算因子值",
selection: "逐择股日选股",
backtesting: "撮合与净值结算",
analysis: "汇总指标与曲线",
done: "完成",
};
-/** 执行阶段时间线(顺序即推进顺序,用于「阶段小圆点」) */
-export const STAGE_ORDER = ["queued", "data_loading", "selection", "backtesting", "analysis", "done"];
+/** 作业类型 —— **只列后端真的会异步执行的 kind**:
+ * - `signals` 不在其中:`POST /api/signals` 是同步接口(没有 /signals/jobs),没有作业阶段可言;
+ * - `combo`(回测组合)也不单列:它上报的阶段与 backtest 完全一致(证据见下),直接映射到 "backtest"。
+ */
+export type JobKind = "factor_test" | "backtest" | "selection";
+
+/**
+ * 每种作业**真实会走**的阶段序列 —— 只放后端真的会上报的阶段,**不含 `queued` / `done`**:
+ * 「排队中」「已完成」是作业**状态**(由 `phase` 表达,见 JobRunState),不是后端上报的执行阶段,
+ * 混进圆点序列会让「尚未开始的第一个圆点」和「已结束」看起来像同一条进度。
+ *
+ * 为什么必须按作业类型分开:全站共用一条「阶段小圆点」之后,用一张全局表会把因子测试
+ * 根本不存在的阶段(逐择股日选股 / 撮合与净值结算)也画出来 —— 那正是要避免的
+ * 「看似有数据的假象」;反过来,后端新阶段如果不在表里,圆点会整排变灰。
+ *
+ * 证据(后端上报点,改动前请重新 grep 核对,勿凭印象):
+ * - factor_test:backend/app/quant/service.py:304 data_loading → :306 factor_calculation → :308 analysis
+ * - backtest :backend/app/quant/service.py:314 data_loading → :316 backtesting → :319 analysis
+ * - selection :backend/app/application/services/job_executor.py:156 selection(真的只有这一段)
+ * - combo(复用 backtest 表):backend/app/application/services/combo_service.py:68 data_loading
+ * → :70 backtesting → :90 analysis;组合的「逐择股日选股」是在 run_combo_backtest 内部完成的,
+ * 后端从未单独上报 selection 阶段,所以回测这条线不放 selection 圆点。
+ */
+export const STAGE_PIPELINES: Record = {
+ factor_test: ["data_loading", "factor_calculation", "analysis"],
+ backtest: ["data_loading", "backtesting", "analysis"],
+ selection: ["selection"],
+};
+
+/** @deprecated 已改为按作业类型取 `STAGE_PIPELINES[kind]`。保留导出仅为兼容旧引用,
+ * 其值等于回测的圆点序列(不再含 queued/done —— 它们是 phase 不是阶段)。 */
+export const STAGE_ORDER = STAGE_PIPELINES.backtest;
/** 取消排队中/执行中的作业(POST /api/jobs/{id}/cancel;已结束的会返回 cancelled=false)。 */
export function cancelJob(