From e58367af277d4567cc1c617451d2c7d78c386c22 Mon Sep 17 00:00:00 2001 From: Simon Date: Thu, 1 Oct 2026 18:37:25 +0800 Subject: [PATCH] =?UTF-8?q?feat(web):=20=E5=9B=A0=E5=AD=90=E7=A0=94?= =?UTF-8?q?=E7=A9=B6/=E5=9B=A0=E5=AD=90=E7=BB=84=E5=90=88/=E8=82=A1?= =?UTF-8?q?=E7=A5=A8=E7=AD=9B=E9=80=89=E6=8E=A5=E5=85=A5=E7=BB=9F=E4=B8=80?= =?UTF-8?q?=E4=BD=9C=E4=B8=9A=E5=8F=8D=E9=A6=88=EF=BC=9B=E9=98=B6=E6=AE=B5?= =?UTF-8?q?=E5=9C=86=E7=82=B9=E6=8C=89=E4=BD=9C=E4=B8=9A=E7=B1=BB=E5=9E=8B?= =?UTF-8?q?=E5=8C=BA=E5=88=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 接着 /backtest 的那次改造,把其余会跑异步 Job 的页面也切到同一套 `useJobRunner` + `JobProgress`(用户原话:包括因子测试等所有测试都帮我完善用户反馈): - **/factors**(一个因子一个 Job 的批量场景):删掉 `running/processed/current/jobId` 与 按「已处理数 ÷ 总数」自算的 `Progress` **假百分比**;每轮把 `第 i/共 n 个` 交给反馈条, 阶段/作业号/已用秒数全部来自后端。取消 = 用户明确意图 → 保留已出的报告、不再跑后续因子, 不报错;单个因子失败仍继续跑其余因子,并把后端原文累积成**批级清单**(多因子时反馈条 只能显示最后一个作业,前几个失败不能丢)。 - **/factors/compose**:删掉 `value={30}` 的假进度条;`archiveId` / 复用 `BacktestResultView` / 「新页面放大」全部保留;failed/cancelled 交给反馈条,页面 error 只留参数与目录错误 (同一失败不在两处各说一遍)。 - **/selection**:异步选股切反馈条;**同步**的「执行选股」保留原 loading(`POST /selections` 没有 job_id,套上会去 `GET /jobs/{signal}` 撞 404)。 - **/signals**:`POST /api/signals` 是同步接口,**不套**作业反馈(不编作业号、不编阶段), 改为点击即现的 `role="status"` 提示,如实写明「同步请求、请求期间不能关页、无阶段无取消、 出错显示后端原文」。 - **阶段圆点按作业类型区分**(修掉一个真实缺陷):原来全站共用一张含 `queued/done` 的 `STAGE_ORDER`,因子测试页实测出现过**裸英文** `factor_calculation` 且 4 个圆点全灰 (`indexOf` = -1),还画出了因子测试根本不存在的「逐择股日选股 / 撮合与净值结算」。 现在 `STAGE_PIPELINES = { factor_test: [加载→计算因子值→汇总], backtest: [加载→撮合→汇总], selection: [逐择股日选股] }`(阶段序列**不含 queued/done**,那是作业状态不是阶段), 未知阶段显示「执行中(stage)」并保留已推进的圆点,不整排灰、不露裸枚举。 验证(真实浏览器 CDP,读数原文已记录): - /factors:点击后 0.2s 内 `submitting`→`queued` + 作业号;+30s Pill「计算因子值」、 圆点 `✓ 加载行情与因子数据 / ● 计算因子值 / ○ 汇总指标与曲线`(无「选股/撮合」、无英文枚举); 成功态给「去对比 / 打开归档」。 - /selection:圆点只有 `逐择股日选股` 一段;取消 → 「已取消,没有归档」; 另实测撞并发上限时如实显示后端原文「系统繁忙:并发研究任务已达上限」。 - /factors/compose:`submitting→queued→撮合与净值结算→success(EXP-…)`,业务结果与 5 处放大入口照旧。 - /backtest 回归:圆点由 4 个变 3 个(去掉后端**从不上报**的 selection 阶段),取消仍「已取消 + 没归档」。 - 自检产生的 12 个实验归档已全部删除(bulk-delete count:12,复查无残留)。 --- docs/USAGE.md | 7 ++ frontend/web/app/backtest/page.tsx | 5 +- frontend/web/app/factors/compose/page.tsx | 84 ++++++++-------- frontend/web/app/factors/page.tsx | 115 +++++++++++----------- frontend/web/app/selection/page.tsx | 61 ++++++++---- frontend/web/app/signals/page.tsx | 15 +++ frontend/web/components/JobProgress.tsx | 33 +++++-- frontend/web/lib/jobs.ts | 42 +++++++- 8 files changed, 228 insertions(+), 134 deletions(-) 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 ? ( -
- - 任务 {jobId || "排队中…"} 后台执行中,完成后结果自动展示在本页。 - - } - /> -
- ) : 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 ? ( -
- - 正在运行:{current?.name}({current?.idx}/{current?.total})· 后台任务{" "} - {jobId || "排队中…"} - - } - /> -
- ) : 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(