feat(web): 因子研究/因子组合/股票筛选接入统一作业反馈;阶段圆点按作业类型区分

接着 /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,复查无残留)。
This commit is contained in:
Simon
2026-10-01 18:37:25 +08:00
parent c974415691
commit e58367af27
8 changed files with 228 additions and 134 deletions
+7
View File
@@ -534,6 +534,13 @@ PYTHONPATH=. .venv/bin/python -m app.cli.restore_experiment_from_job \
> 失败显示后端原文、成功给「打开归档 / 去对比」;反馈条出现时自动滚入视野 > 失败显示后端原文、成功给「打开归档 / 去对比」;反馈条出现时自动滚入视野
> (运行按钮常在长表单底部,不滚过去等于没显示),并带 `role="status" aria-live="polite"`。 > (运行按钮常在长表单底部,不滚过去等于没显示),并带 `role="status" aria-live="polite"`。
> 各页多个作业互不干扰(各自 `anchorId`)。 > 各页多个作业互不干扰(各自 `anchorId`)。
> 阶段小圆点**按作业类型分别定义**(`STAGE_PIPELINES`):因子测试是
> 「加载行情与因子数据 → 计算因子值 → 汇总指标与曲线」,回测/回测组合是
> 「加载行情与因子数据 → 撮合与净值结算 → 汇总指标与曲线」,异步选股只有
> 「逐择股日选股」一段 —— 后端没上报的阶段不会画成「○ 待开始」(那是看似有数据的假象),
> 后端新阶段的文案没跟上时也不会露出裸英文枚举(显示「执行中(stage)」并保留已推进的圆点)。
> `POST /api/signals` 是**同步**接口,页面如实写明「同步请求、没有作业号与阶段、不能取消」,
> 而不是给它编一个作业号。
**图表**:全站统一使用 **TradingView Lightweight Charts**(`components/charts/LwChart.tsx`), **图表**:全站统一使用 **TradingView Lightweight Charts**(`components/charts/LwChart.tsx`),
ECharts 已从依赖中移除。买卖点标记(▲绿=买入 / ▼红=卖出)只落在该 series 真实存在的交易日上, ECharts 已从依赖中移除。买卖点标记(▲绿=买入 / ▼红=卖出)只落在该 series 真实存在的交易日上,
+4 -1
View File
@@ -646,12 +646,15 @@ function BacktestInner() {
</div> </div>
</div> </div>
{/* 反馈条紧贴运行按钮:点了就有字,且在排队阶段就能取消 */} {/* 反馈条紧贴运行按钮:点了就有字,且在排队阶段就能取消。
kind="backtest":本页跑的是 combo 作业,但后端上报的阶段与 backtest 完全一致
(combo_service.py:68/70/90),所以共用同一条阶段序列。 */}
<JobProgress <JobProgress
state={job.state} state={job.state}
onCancel={job.cancel} onCancel={job.cancel}
onDismiss={job.reset} onDismiss={job.reset}
anchorId="backtest-job-progress" anchorId="backtest-job-progress"
kind="backtest"
/> />
{job.state.phase === "idle" && running ? ( {job.state.phase === "idle" && running ? (
<div className="hint" style={{ marginTop: 12 }} role="status" aria-live="polite"> <div className="hint" style={{ marginTop: 12 }} role="status" aria-live="polite">
+40 -44
View File
@@ -2,7 +2,7 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { apiGet } from "@/lib/api"; import { apiGet } from "@/lib/api";
import { submitJob, waitJob } from "@/lib/jobs"; import { submitJob, useJobRunner } from "@/lib/jobs";
import { factorLabel, pickableFactors } from "@/lib/factors"; import { factorLabel, pickableFactors } from "@/lib/factors";
import type { BacktestResult, FactorMeta, ResearchSpec } from "@/lib/types"; import type { BacktestResult, FactorMeta, ResearchSpec } from "@/lib/types";
import { recentRange } from "@/lib/dates"; import { recentRange } from "@/lib/dates";
@@ -13,10 +13,10 @@ import {
Field, Field,
Btn, Btn,
Banner, Banner,
Progress,
Signed, Signed,
} from "@/components/ui"; } from "@/components/ui";
import { BacktestResultView } from "@/components/BacktestResultView"; import { BacktestResultView } from "@/components/BacktestResultView";
import { JobProgress } from "@/components/JobProgress";
import { Icon } from "@/components/icons"; import { Icon } from "@/components/icons";
interface Pick { interface Pick {
@@ -35,8 +35,16 @@ export default function ComposePage() {
const [result, setResult] = useState<BacktestResult | null>(null); const [result, setResult] = useState<BacktestResult | null>(null);
/** 本次结果的归档 id:结果区的「新页面放大」从归档读同一份数据 */ /** 本次结果的归档 id:结果区的「新页面放大」从归档读同一份数据 */
const [archiveId, setArchiveId] = useState<string | null>(null); const [archiveId, setArchiveId] = useState<string | null>(null);
const [running, setRunning] = useState(false); /**
const [jobId, setJobId] = useState(""); * 作业反馈统一交给 `useJobRunner`(与 /backtest 同一套):点了就有「正在提交作业…」,
* 之后是后端真实阶段 + 每秒自增的已用时间 + 作业号 + 可取消。
* 此前这里只有一句「任务 JOB-xxx 后台执行中」的假进度条(value 固定 30),
* 用户既不知道提交成功没、也不知道跑到哪一步。
*/
const job = useJobRunner<BacktestResult>("因子组合回测");
const active =
job.state.phase === "submitting" || job.state.phase === "queued" || job.state.phase === "running";
/** 只留「参数不合法 / 目录加载失败」这类**作业之外**的错误;作业自身的失败由反馈条显示后端原文 */
const [error, setError] = useState(""); const [error, setError] = useState("");
useEffect(() => { useEffect(() => {
@@ -82,35 +90,27 @@ export default function ComposePage() {
setError("请选择至少一个因子并设置大于 0 的权重"); setError("请选择至少一个因子并设置大于 0 的权重");
return; return;
} }
setRunning(true);
setError(""); setError("");
setJobId("");
setResult(null); setResult(null);
setArchiveId(null); // 新一次运行:先清掉上一次的归档 id,避免放大到旧结果 setArchiveId(null); // 新一次运行:先清掉上一次的归档 id,避免放大到旧结果
try { const spec: ResearchSpec = {
const spec: ResearchSpec = { type: "backtest",
type: "backtest", universe: { exclude_st: excludeSt, min_listing_days: 0 },
universe: { exclude_st: excludeSt, min_listing_days: 0 }, factors: valid.map((p) => ({ name: p.name, weight: p.weight })),
factors: valid.map((p) => ({ name: p.name, weight: p.weight })), selection: { top_n: topN },
selection: { top_n: topN }, rebalance,
rebalance, period: [start, end],
period: [start, end], };
}; const out = await job.run(() => submitJob(spec), {
const { job_id } = await submitJob(spec); label: `因子组合回测(${valid.length} 个因子)`,
setJobId(job_id); });
const out = await waitJob<BacktestResult>(job_id); if (out.status === "success" && out.result) {
if (out.status === "success" && out.result) { setResult(out.result);
setResult(out.result); // 归档 id 交给结果区做「新页面放大」;反馈条自己也会给「打开归档 / 去对比」
setArchiveId(out.experimentId ?? null); setArchiveId(out.experimentId ?? null);
} else {
setError(`任务${out.status}${out.error ? `:${out.error}` : ""}`);
}
} catch (e) {
setError((e as Error).message);
} finally {
setRunning(false);
setJobId("");
} }
// 失败 / 超时 / 取消都只在反馈条上说:后端原文(out.error)、
// 「已取消(不产生归档)」都在 JobProgress 里,页面不再另写一份 Banner 重复同一个失败
} }
return ( return (
@@ -266,26 +266,22 @@ export default function ComposePage() {
<Btn <Btn
variant="primary" variant="primary"
icon="play" icon="play"
loading={running} loading={active}
disabled={running || picks.length === 0} disabled={active || picks.length === 0}
onClick={run} onClick={run}
> >
{running ? "后台运行中…" : `回测组合(${picks.length} 个因子)`} {active ? "后台运行中…" : `回测组合(${picks.length} 个因子)`}
</Btn> </Btn>
</div> </div>
{running ? ( {/* 反馈条紧贴运行按钮:提交瞬间就有字,排队阶段就能取消,完成后给归档入口 */}
<div style={{ marginTop: 14 }}> <JobProgress
<Progress state={job.state}
value={30} onCancel={job.cancel}
label={ onDismiss={job.reset}
<> anchorId="factors-compose-job-progress"
任务 <span className="mono">{jobId || "排队中…"}</span> 后台执行中,完成后结果自动展示在本页。 kind="backtest"
</> />
}
/>
</div>
) : null}
{error ? <div style={{ marginTop: 12 }}><Banner tone="error">{error}</Banner></div> : null} {error ? <div style={{ marginTop: 12 }}><Banner tone="error">{error}</Banner></div> : null}
</Card> </Card>
+55 -60
View File
@@ -2,7 +2,7 @@
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import { apiGet, apiPatch, apiPost } from "@/lib/api"; import { apiGet, apiPatch, apiPost } from "@/lib/api";
import { submitJob, waitJob } from "@/lib/jobs"; import { submitJob, useJobRunner } from "@/lib/jobs";
import type { import type {
FactorMeta, FactorMeta,
FactorParam, FactorParam,
@@ -19,10 +19,10 @@ import {
Field, Field,
Btn, Btn,
Banner, Banner,
Progress,
Empty, Empty,
SkeletonLines, SkeletonLines,
} from "@/components/ui"; } from "@/components/ui";
import { JobProgress } from "@/components/JobProgress";
/** /**
* 因子研究页。 * 因子研究页。
@@ -180,10 +180,15 @@ export default function FactorsPage() {
const [start, setStart] = useState(""); const [start, setStart] = useState("");
const [end, setEnd] = useState(""); const [end, setEnd] = useState("");
const [reports, setReports] = useState<Record<string, FactorTestReport>>({}); const [reports, setReports] = useState<Record<string, FactorTestReport>>({});
const [running, setRunning] = useState(false); /**
const [processed, setProcessed] = useState(0); * 作业反馈统一交给 `useJobRunner`:本页是「一个因子一个 Job」的批量场景,
const [current, setCurrent] = useState<{ idx: number; total: number; name: string } | null>(null); * 每轮把 (第 i/共 n 个) 作为 progress 交给反馈条 —— 阶段、作业号、已用秒数全部来自后端,
const [jobId, setJobId] = useState(""); * 不再由前端按 processed/selected 算一个百分比假进度条(用户看不出到底跑到哪了)。
*/
const job = useJobRunner<FactorTestReport>("单因子测试");
/** 反馈条是否处于「跑着」的状态:用于禁用按钮、让位给反馈条的提示位。 */
const active =
job.state.phase === "submitting" || job.state.phase === "queued" || job.state.phase === "running";
const [error, setError] = useState(""); const [error, setError] = useState("");
/** 目录级成功提示(停用 / 启用)。 */ /** 目录级成功提示(停用 / 启用)。 */
const [notice, setNotice] = useState(""); const [notice, setNotice] = useState("");
@@ -319,47 +324,41 @@ export default function FactorsPage() {
setError("请至少选择一个因子"); setError("请至少选择一个因子");
return; return;
} }
setRunning(true);
setError(""); setError("");
setJobId("");
setReports({}); setReports({});
setProcessed(0); // 多因子是**一个因子一个 Job**(后端逐因子归档),所以这里顺序提交、逐个 await;
try { // 每轮都把进度交给统一反馈条:点了第一个就有「正在提交作业…」+ 作业号,不必等全部跑完。
let index = 0; let index = 0;
for (const name of names) { for (const name of names) {
index += 1; index += 1;
setCurrent({ idx: index, total: names.length, name }); const spec: ResearchSpec = {
const spec: ResearchSpec = { type: "factor_test",
type: "factor_test", universe: { exclude_st: true, min_listing_days: 0 },
universe: { exclude_st: true, min_listing_days: 0 }, factors: [{ name, weight: 1 }],
factors: [{ name, weight: 1 }], selection: { top_n: 10 },
selection: { top_n: 10 }, rebalance: "monthly",
rebalance: "monthly", period: [start, end],
period: [start, end], };
}; const out = await job.run(() => submitJob(spec), {
const { job_id } = await submitJob(spec); label: `单因子测试 · ${name}`,
setJobId(job_id); progress: { index, total: names.length },
const out = await waitJob<FactorTestReport>(job_id); });
if (out.status === "success" && out.result) { if (out.status === "success" && out.result) {
setReports((prev) => ({ ...prev, [name]: out.result! })); setReports((prev) => ({ ...prev, [name]: out.result! }));
} else { } else if (out.status === "cancelled") {
const msg = `因子 ${name}:${out.status}${out.error ? `:${out.error}` : ""}`; // 取消是用户的明确意图,不是错误:已经跑完的报告留在页面上,后面的因子不再跑
setError((prev) => (prev ? `${prev}\n${msg}` : msg)); break;
} } else {
setProcessed(index); // 单个因子失败不该吞掉其它因子的报告:把后端原文累积成一份批级错误清单,
// 逐个跑(而不是整批放弃)也和老行为一致
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 selectedCount = checked.size;
const reportNames = Object.keys(reports); 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 tpl = templates.find((x) => x.name === tplName) ?? null;
const tplSpecs = tpl?.param_specs ?? []; const tplSpecs = tpl?.param_specs ?? [];
@@ -372,12 +371,12 @@ export default function FactorsPage() {
actions={<Pill tone="accent" icon="flask">{reportNames.length}/{selectedCount} 个已出报告</Pill>} actions={<Pill tone="accent" icon="flask">{reportNames.length}/{selectedCount} 个已出报告</Pill>}
/> />
{error && !running ? ( {error && !active ? (
<Banner tone="error"> <Banner tone="error">
<pre style={{ margin: 0, whiteSpace: "pre-line", font: "inherit" }}>{error}</pre> <pre style={{ margin: 0, whiteSpace: "pre-line", font: "inherit" }}>{error}</pre>
</Banner> </Banner>
) : null} ) : null}
{notice && !running ? <Banner tone="info">{notice}</Banner> : null} {notice && !active ? <Banner tone="info">{notice}</Banner> : null}
<Card <Card
title="因子目录" title="因子目录"
@@ -579,7 +578,7 @@ export default function FactorsPage() {
)} )}
</Card> </Card>
<Card title="运行单因子测试" icon="play" tools={running ? <Pill tone="accent" icon="spinner">执行中</Pill> : undefined}> <Card title="运行单因子测试" icon="play" tools={active ? <Pill tone="accent" icon="spinner">执行中</Pill> : undefined}>
<div className="form-grid"> <div className="form-grid">
<Field label="开始日期"> <Field label="开始日期">
<input type="date" className="input" value={start} onChange={(e) => setStart(e.target.value)} /> <input type="date" className="input" value={start} onChange={(e) => setStart(e.target.value)} />
@@ -590,34 +589,30 @@ export default function FactorsPage() {
<Btn <Btn
variant="primary" variant="primary"
icon="play" icon="play"
loading={running} loading={active}
disabled={running || selectedCount === 0} disabled={active || selectedCount === 0}
onClick={run} onClick={run}
> >
{running {/* 按钮上只保留「跑第几个」这一条信息;秒表 / 阶段 / 作业号都在反馈条上,不重复 */}
? `运行中 ${processed}/${selectedCount}` {active
? `运行中 ${job.state.progress?.index ?? 0}/${job.state.progress?.total ?? selectedCount}`
: selectedCount === 0 : selectedCount === 0
? "请先勾选因子" ? "请先勾选因子"
: `运行因子测试(${selectedCount} 个)`} : `运行因子测试(${selectedCount} 个)`}
</Btn> </Btn>
</div> </div>
{running ? ( {/* 反馈条:点了立刻有字(正在提交作业…),随后是真实阶段 + 每秒自增的已用时间 + 可取消 */}
<div style={{ marginTop: 14 }}> <JobProgress
<Progress state={job.state}
value={progressPct} onCancel={job.cancel}
label={ onDismiss={job.reset}
<> anchorId="factors-job-progress"
正在运行:<b>{current?.name}</b>({current?.idx}/{current?.total})· 后台任务{" "} kind="factor_test"
<span className="mono">{jobId || "排队中…"}</span> />
</>
}
/>
</div>
) : null}
</Card> </Card>
{reportNames.length === 0 && !running && !error ? ( {reportNames.length === 0 && !active && !error ? (
<Card> <Card>
<Empty <Empty
icon="chartLine" icon="chartLine"
+41 -20
View File
@@ -8,7 +8,7 @@ import Link from "next/link";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { apiGet, apiPost } from "@/lib/api"; import { apiGet, apiPost } from "@/lib/api";
import { SymbolLink } from "@/lib/symbols"; import { SymbolLink } from "@/lib/symbols";
import { waitJob } from "@/lib/jobs"; import { useJobRunner } from "@/lib/jobs";
import { factorOptionLabel, pickableFactors } from "@/lib/factors"; import { factorOptionLabel, pickableFactors } from "@/lib/factors";
import type { import type {
FactorMeta, FactorMeta,
@@ -28,6 +28,7 @@ import {
Empty, Empty,
SkeletonLines, SkeletonLines,
} from "@/components/ui"; } from "@/components/ui";
import { JobProgress } from "@/components/JobProgress";
const FIELD_OPTIONS: { value: string; label: string; kind: "num" | "str" | "ref" }[] = [ const FIELD_OPTIONS: { value: string; label: string; kind: "num" | "str" | "ref" }[] = [
{ value: "static.industry", label: "行业 industry", kind: "str" }, { value: "static.industry", label: "行业 industry", kind: "str" },
@@ -80,8 +81,22 @@ export default function SelectionPage() {
* 拿它拼 /backtest?from_selection=<JOB id> 会让接收端 GET /api/selections/{id} 404 → 坏链接。 * 拿它拼 /backtest?from_selection=<JOB id> 会让接收端 GET /api/selections/{id} 404 → 坏链接。
*/ */
const [savedSelectionId, setSavedSelectionId] = useState(""); const [savedSelectionId, setSavedSelectionId] = useState("");
/**
* 同步选股(POST /selections)正在执行 —— 这条路径是**同步接口**,没有 job_id,
* 所以不能套 useJobRunner(它要轮询 /jobs/{id})。保留 running 只用于这条路径的按钮态。
*/
const [running, setRunning] = useState(false); const [running, setRunning] = useState(false);
const [asyncBusy, setAsyncBusy] = useState(false); /**
* 异步选股(POST /selections/jobs)的作业反馈统一交给 `useJobRunner`:
* 提交瞬间就有「正在提交作业…」,之后是真实阶段 + 每秒自增的已用时间 + 作业号 + 可取消。
* 此前只有一个 asyncBusy 布尔值,用户点了「异步(全市场)」只看到按钮变灰,
* 不知道到底提交成功没、作业号是多少(正是「点了没反应」的投诉点)。
*/
const job = useJobRunner<SelectionResult>("异步选股(全市场)");
/** 异步作业是否在跑:既用于禁用两个按钮,也用于切换异步按钮的文案。 */
const asyncActive =
job.state.phase === "submitting" || job.state.phase === "queued" || job.state.phase === "running";
/** 只留「同步选股失败 / 历史读取失败」这类**作业之外**的错误;异步作业的失败由反馈条显示后端原文 */
const [error, setError] = useState(""); const [error, setError] = useState("");
const [history, setHistory] = useState<SelectionMeta[] | null>(null); const [history, setHistory] = useState<SelectionMeta[] | null>(null);
@@ -146,26 +161,24 @@ export default function SelectionPage() {
/** 异步(全市场等长任务):提交 Job 后台执行并轮询(解决同步 60s+)。 */ /** 异步(全市场等长任务):提交 Job 后台执行并轮询(解决同步 60s+)。 */
async function runAsync() { async function runAsync() {
setAsyncBusy(true);
setError(""); setError("");
setResult(null); setResult(null);
// 同上:异步任务不落库选股记录,直通回测必须隐藏按钮(不给坏链接) // 同上:异步任务不落库选股记录,直通回测必须隐藏按钮(不给坏链接)
setSavedSelectionId(""); setSavedSelectionId("");
try { // 提交闭包把真实 job_id 记下来:页头那个 Pill 沿用老行为显示本次运行的 id,
const { job_id } = await apiPost<{ job_id: string }>("/selections/jobs", buildQuery()); // 而 job.state.jobId 在 await 之后读到的仍是本轮渲染的旧值(React 状态不会在同一 tick 内更新)
const out = await waitJob<SelectionResult>(job_id); let submittedId = "";
if (out.status === "success" && out.result) { const out = await job.run(async () => {
setSelectionId(job_id); const r = await apiPost<{ job_id: string }>("/selections/jobs", buildQuery());
setResult(out.result); submittedId = r.job_id;
apiGet<SelectionMeta[]>("/selections?limit=8").then(setHistory).catch(() => undefined); return r;
} else { });
setError(`任务${out.status}${out.error ? `:${out.error}` : ""}`); if (out.status === "success" && out.result) {
} setSelectionId(submittedId);
} catch (e) { setResult(out.result);
setError((e as Error).message); apiGet<SelectionMeta[]>("/selections?limit=8").then(setHistory).catch(() => undefined);
} finally {
setAsyncBusy(false);
} }
// 失败 / 超时 / 取消:反馈条已给出后端原文或「已取消(不产生归档)」,不在这里重复报错
} }
async function openHistory(id: string) { async function openHistory(id: string) {
@@ -378,13 +391,21 @@ export default function SelectionPage() {
)} )}
<div style={{ marginTop: 14 }}> <div style={{ marginTop: 14 }}>
<Btn variant="primary" icon="play" loading={running} disabled={running || asyncBusy} onClick={run}> <Btn variant="primary" icon="play" loading={running} disabled={running || asyncActive} onClick={run}>
{running ? "筛选中…" : "执行选股"} {running ? "筛选中…" : "执行选股"}
</Btn> </Btn>
<Btn variant="ghost" icon="clock" loading={asyncBusy} disabled={running || asyncBusy} onClick={runAsync}> <Btn variant="ghost" icon="clock" loading={asyncActive} disabled={running || asyncActive} onClick={runAsync}>
{asyncBusy ? "后台执行中…" : "异步(全市场)"} {asyncActive ? "后台执行中…" : "异步(全市场)"}
</Btn> </Btn>
</div> </div>
{/* 异步作业的反馈条:点了立刻有字 + 真实阶段 + 可取消(同步按钮不走这里) */}
<JobProgress
state={job.state}
onCancel={job.cancel}
onDismiss={job.reset}
anchorId="selection-job-progress"
kind="selection"
/>
{error ? ( {error ? (
<div style={{ marginTop: 12 }}> <div style={{ marginTop: 12 }}>
<Banner tone="error">{error}</Banner> <Banner tone="error">{error}</Banner>
+15
View File
@@ -138,6 +138,21 @@ export default function SignalsPage() {
{running ? "生成中…" : "生成信号"} {running ? "生成中…" : "生成信号"}
</Btn> </Btn>
</div> </div>
{/*
为什么这里没有 JobProgress:`POST /signals` 是**同步**接口(backend/app/api/signals.py
只有 POST "",没有 /signals/jobs),拿不到 job_id,也就无从轮询阶段 —— 套 useJobRunner
会去 GET /jobs/{signal_id} 撞 404,而合成一个假的 JobRunState 更是「看似有数据的假象」。
所以这里如实说清三件事:同步连接、没有作业号/阶段/取消、出错时显示后端原文。
*/}
{running ? (
<div className="hint" style={{ marginTop: 12 }} role="status" aria-live="polite">
同步请求已发出:请求期间浏览器一直连着后端(这条路径不是后台作业,不能关页离开)。
因为是同步接口,<b>没有作业号与执行阶段,也没有取消按钮</b>。
该请求通常在数秒内返回;若后端报错或连接中断,下方会显示后端原文。
</div>
) : null}
{error ? <div style={{ marginTop: 12 }}><Banner tone="error">{error}</Banner></div> : null} {error ? <div style={{ marginTop: 12 }}><Banner tone="error">{error}</Banner></div> : null}
</Card> </Card>
+27 -6
View File
@@ -13,12 +13,12 @@
* 不滚一下用户就看不见(这正是「没有反馈」错觉的来源之一)。 * 不滚一下用户就看不见(这正是「没有反馈」错觉的来源之一)。
*/ */
import { useEffect, useRef } from "react"; import { useEffect, useRef, useState } from "react";
import Link from "next/link"; import Link from "next/link";
import { Btn, Pill } from "@/components/ui"; import { Btn, Pill } from "@/components/ui";
import { Icon } from "@/components/icons"; 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(跑几分钟时更好读) */ /** 已用时间:< 60s 显示秒,否则 mm:ss(跑几分钟时更好读) */
function fmtElapsed(ms: number): string { function fmtElapsed(ms: number): string {
@@ -38,15 +38,36 @@ export function JobProgress<T>({
onDismiss, onDismiss,
/** 完成后是否自动滚到视野(提交时恒滚,完成后不打断阅读) */ /** 完成后是否自动滚到视野(提交时恒滚,完成后不打断阅读) */
anchorId = "job-progress", anchorId = "job-progress",
/** 作业类型:决定阶段小圆点画哪几条 —— 因子测试没有「逐择股日选股 / 撮合与净值结算」,
* 选股也只有一个真实阶段,全站共用一张表会把这些不存在的阶段画成「○ 待开始」。 */
kind = "backtest",
}: { }: {
state: JobRunState<T>; state: JobRunState<T>;
onCancel?: () => void; onCancel?: () => void;
onDismiss?: () => void; onDismiss?: () => void;
anchorId?: string; anchorId?: string;
kind?: JobKind;
}) { }) {
const ref = useRef<HTMLDivElement | null>(null); const ref = useRef<HTMLDivElement | null>(null);
const scrolled = useRef(false); const scrolled = useRef(false);
const active = state.phase === "submitting" || state.phase === "queued" || state.phase === "running"; 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<T>({
const stageText = state.stage const stageText = state.stage
? state.stage === "cancelling" ? 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 const archiveHref = state.experimentId
? `/experiments?exp=${encodeURIComponent(state.experimentId)}` ? `/experiments?exp=${encodeURIComponent(state.experimentId)}`
: null; : null;
@@ -158,8 +179,8 @@ export function JobProgress<T>({
{active ? ( {active ? (
<div className="stages" style={{ marginTop: 8 }}> <div className="stages" style={{ marginTop: 8 }}>
{["data_loading", "selection", "backtesting", "analysis"].map((st) => { {pipeline.map((st) => {
const mine = STAGE_ORDER.indexOf(st); const mine = pipeline.indexOf(st);
const cls = cur >= 0 && mine < cur ? "stage is-done" : mine === cur ? "stage is-active" : "stage"; const cls = cur >= 0 && mine < cur ? "stage is-done" : mine === cur ? "stage is-active" : "stage";
return ( return (
<span className={cls} key={st}> <span className={cls} key={st}>
+39 -3
View File
@@ -67,18 +67,54 @@ export async function waitJob<T>(
return { status: "timeout", result: null, error: "等待结果超时,请稍后在「实验」页查看归档" }; return { status: "timeout", result: null, error: "等待结果超时,请稍后在「实验」页查看归档" };
} }
/** 阶段中文名(后端 stage 枚举 → 用户可读) */ /** 阶段中文名(后端 stage 枚举 → 用户可读)。
*
* 必须覆盖每种作业**上报过的全部阶段**:漏一个,JobProgress 只能把裸枚举摆给用户
* —— 实测漏过 `factor_calculation`(/factors 反馈条上直接出现过英文 `factor_calculation`)。
* 新增阶段时请同时补这里的中文名与下方 `STAGE_PIPELINES` 里对应的序列。
*/
export const STAGE_LABEL: Record<string, string> = { export const STAGE_LABEL: Record<string, string> = {
queued: "排队中", queued: "排队中",
data_loading: "加载行情与因子数据", data_loading: "加载行情与因子数据",
factor_calculation: "计算因子值",
selection: "逐择股日选股", selection: "逐择股日选股",
backtesting: "撮合与净值结算", backtesting: "撮合与净值结算",
analysis: "汇总指标与曲线", analysis: "汇总指标与曲线",
done: "完成", done: "完成",
}; };
/** 执行阶段时间线(顺序即推进顺序,用于「阶段小圆点」) */ /** 作业类型 —— **只列后端真的会异步执行的 kind**:
export const STAGE_ORDER = ["queued", "data_loading", "selection", "backtesting", "analysis", "done"]; * - `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<JobKind, string[]> = {
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)。 */ /** 取消排队中/执行中的作业(POST /api/jobs/{id}/cancel;已结束的会返回 cancelled=false)。 */
export function cancelJob( export function cancelJob(