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
+4 -1
View File
@@ -646,12 +646,15 @@ function BacktestInner() {
</div>
</div>
{/* 反馈条紧贴运行按钮:点了就有字,且在排队阶段就能取消 */}
{/* 反馈条紧贴运行按钮:点了就有字,且在排队阶段就能取消。
kind="backtest":本页跑的是 combo 作业,但后端上报的阶段与 backtest 完全一致
(combo_service.py:68/70/90),所以共用同一条阶段序列。 */}
<JobProgress
state={job.state}
onCancel={job.cancel}
onDismiss={job.reset}
anchorId="backtest-job-progress"
kind="backtest"
/>
{job.state.phase === "idle" && running ? (
<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 { 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<BacktestResult | null>(null);
/** 本次结果的归档 id:结果区的「新页面放大」从归档读同一份数据 */
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("");
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<BacktestResult>(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() {
<Btn
variant="primary"
icon="play"
loading={running}
disabled={running || picks.length === 0}
loading={active}
disabled={active || picks.length === 0}
onClick={run}
>
{running ? "后台运行中…" : `回测组合(${picks.length} 个因子)`}
{active ? "后台运行中…" : `回测组合(${picks.length} 个因子)`}
</Btn>
</div>
{running ? (
<div style={{ marginTop: 14 }}>
<Progress
value={30}
label={
<>
任务 <span className="mono">{jobId || "排队中…"}</span> 后台执行中,完成后结果自动展示在本页。
</>
}
/>
</div>
) : null}
{/* 反馈条紧贴运行按钮:提交瞬间就有字,排队阶段就能取消,完成后给归档入口 */}
<JobProgress
state={job.state}
onCancel={job.cancel}
onDismiss={job.reset}
anchorId="factors-compose-job-progress"
kind="backtest"
/>
{error ? <div style={{ marginTop: 12 }}><Banner tone="error">{error}</Banner></div> : null}
</Card>
+55 -60
View File
@@ -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<Record<string, FactorTestReport>>({});
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<FactorTestReport>("单因子测试");
/** 反馈条是否处于「跑着」的状态:用于禁用按钮、让位给反馈条的提示位。 */
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<FactorTestReport>(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={<Pill tone="accent" icon="flask">{reportNames.length}/{selectedCount} 个已出报告</Pill>}
/>
{error && !running ? (
{error && !active ? (
<Banner tone="error">
<pre style={{ margin: 0, whiteSpace: "pre-line", font: "inherit" }}>{error}</pre>
</Banner>
) : null}
{notice && !running ? <Banner tone="info">{notice}</Banner> : null}
{notice && !active ? <Banner tone="info">{notice}</Banner> : null}
<Card
title="因子目录"
@@ -579,7 +578,7 @@ export default function FactorsPage() {
)}
</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">
<Field label="开始日期">
<input type="date" className="input" value={start} onChange={(e) => setStart(e.target.value)} />
@@ -590,34 +589,30 @@ export default function FactorsPage() {
<Btn
variant="primary"
icon="play"
loading={running}
disabled={running || selectedCount === 0}
loading={active}
disabled={active || selectedCount === 0}
onClick={run}
>
{running
? `运行中 ${processed}/${selectedCount}`
{/* 按钮上只保留「跑第几个」这一条信息;秒表 / 阶段 / 作业号都在反馈条上,不重复 */}
{active
? `运行中 ${job.state.progress?.index ?? 0}/${job.state.progress?.total ?? selectedCount}`
: selectedCount === 0
? "请先勾选因子"
: `运行因子测试(${selectedCount} 个)`}
</Btn>
</div>
{running ? (
<div style={{ marginTop: 14 }}>
<Progress
value={progressPct}
label={
<>
正在运行:<b>{current?.name}</b>({current?.idx}/{current?.total})· 后台任务{" "}
<span className="mono">{jobId || "排队中…"}</span>
</>
}
/>
</div>
) : null}
{/* 反馈条:点了立刻有字(正在提交作业…),随后是真实阶段 + 每秒自增的已用时间 + 可取消 */}
<JobProgress
state={job.state}
onCancel={job.cancel}
onDismiss={job.reset}
anchorId="factors-job-progress"
kind="factor_test"
/>
</Card>
{reportNames.length === 0 && !running && !error ? (
{reportNames.length === 0 && !active && !error ? (
<Card>
<Empty
icon="chartLine"
+41 -20
View File
@@ -8,7 +8,7 @@ import Link from "next/link";
import { useEffect, useState } from "react";
import { apiGet, apiPost } from "@/lib/api";
import { SymbolLink } from "@/lib/symbols";
import { waitJob } from "@/lib/jobs";
import { useJobRunner } from "@/lib/jobs";
import { factorOptionLabel, pickableFactors } from "@/lib/factors";
import type {
FactorMeta,
@@ -28,6 +28,7 @@ import {
Empty,
SkeletonLines,
} from "@/components/ui";
import { JobProgress } from "@/components/JobProgress";
const FIELD_OPTIONS: { value: string; label: string; kind: "num" | "str" | "ref" }[] = [
{ 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 → 坏链接。
*/
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<SelectionResult>("异步选股(全市场)");
/** 异步作业是否在跑:既用于禁用两个按钮,也用于切换异步按钮的文案。 */
const asyncActive =
job.state.phase === "submitting" || job.state.phase === "queued" || job.state.phase === "running";
/** 只留「同步选股失败 / 历史读取失败」这类**作业之外**的错误;异步作业的失败由反馈条显示后端原文 */
const [error, setError] = useState("");
const [history, setHistory] = useState<SelectionMeta[] | null>(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<SelectionResult>(job_id);
if (out.status === "success" && out.result) {
setSelectionId(job_id);
setResult(out.result);
apiGet<SelectionMeta[]>("/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<SelectionMeta[]>("/selections?limit=8").then(setHistory).catch(() => undefined);
}
// 失败 / 超时 / 取消:反馈条已给出后端原文或「已取消(不产生归档)」,不在这里重复报错
}
async function openHistory(id: string) {
@@ -378,13 +391,21 @@ export default function SelectionPage() {
)}
<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 ? "筛选中…" : "执行选股"}
</Btn>
<Btn variant="ghost" icon="clock" loading={asyncBusy} disabled={running || asyncBusy} onClick={runAsync}>
{asyncBusy ? "后台执行中…" : "异步(全市场)"}
<Btn variant="ghost" icon="clock" loading={asyncActive} disabled={running || asyncActive} onClick={runAsync}>
{asyncActive ? "后台执行中…" : "异步(全市场)"}
</Btn>
</div>
{/* 异步作业的反馈条:点了立刻有字 + 真实阶段 + 可取消(同步按钮不走这里) */}
<JobProgress
state={job.state}
onCancel={job.cancel}
onDismiss={job.reset}
anchorId="selection-job-progress"
kind="selection"
/>
{error ? (
<div style={{ marginTop: 12 }}>
<Banner tone="error">{error}</Banner>
+15
View File
@@ -138,6 +138,21 @@ export default function SignalsPage() {
{running ? "生成中…" : "生成信号"}
</Btn>
</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}
</Card>