feat(web): 回测页统一作业反馈条(点了立刻有字 / 真实阶段 / 可取消)
用户反馈:「点击回测没有任何反馈,不清楚是不是已经开始回测」。
- 新增全站统一的 `useJobRunner`(`lib/jobs.ts`)与 `JobProgress` 组件:
提交瞬间即显示「排队中 + 作业号 + 发起时间」,已用时间**每秒自增**(不依赖后端
是否报新阶段),阶段来自后端真实 `stage`,排队/运行中可**取消任务**、失败直接显示
后端原文、成功给「打开归档 / 去对比」。反馈条出现时自动滚入视野(按钮在长表单底部,
不滚过去就等于没显示);`role="status" aria-live="polite"`。
- `/backtest` 改用它:删掉原先手写的阶段条与 stage/elapsed/jobId 三个 state,
取消走 `POST /jobs/{id}/cancel`,取消后如实提示「已取消,没有归档」而不是报错。
- 顺带修掉「未命名组合点运行 → 撞 422」:运行是**临时组合不落库**,未填名字时按
「未命名组合(仅本次运行)」提交并明确提示,不再把后端 422 原文甩给用户。
- 修掉反馈条里 `**加粗**` 字面残留(hint 不走 Markdown)。
验证(真实浏览器 CDP,实测数字):
- 勾选策略 → 点「运行回测组合」→ **+0.11s** 反馈条已显示 `排队中` + `JOB-8508F84D`
+ 发起于 18:05:28 + 已用 0s + 四个真实阶段 + 「取消任务」按钮;+4.5s 进入
`加载行情与因子数据`;点取消 → 「正在取消…」→「已取消」并提示没有归档。
- 反馈条截图逐字核对(阶段、作业号、时间、按钮),并据此修掉 markdown 残留。
This commit is contained in:
@@ -18,11 +18,12 @@ import { Suspense, useCallback, useEffect, useMemo, useRef, useState } from "rea
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useSearchParams } from "next/navigation";
|
import { useSearchParams } from "next/navigation";
|
||||||
import { apiDelete, apiGet, apiPost, apiPut } from "@/lib/api";
|
import { apiDelete, apiGet, apiPost, apiPut } from "@/lib/api";
|
||||||
import { STAGE_LABEL, runSavedCombo, submitComboJob, waitJob } from "@/lib/jobs";
|
import { runSavedCombo, submitComboJob, useJobRunner } from "@/lib/jobs";
|
||||||
import { recentRange } from "@/lib/dates";
|
import { recentRange } from "@/lib/dates";
|
||||||
import type { BacktestCombo, BacktestResult, GlobalConfig, SelectionStrategy } from "@/lib/types";
|
import type { BacktestCombo, BacktestResult, GlobalConfig, SelectionStrategy } from "@/lib/types";
|
||||||
import { PageHeader, Card, Pill, Btn, Banner, Empty, Loading, Field, Progress, BacktestMetrics } from "@/components/ui";
|
import { PageHeader, Card, Pill, Btn, Banner, Empty, Loading, Field, Progress, BacktestMetrics } from "@/components/ui";
|
||||||
import { BacktestResultView } from "@/components/BacktestResultView";
|
import { BacktestResultView } from "@/components/BacktestResultView";
|
||||||
|
import { JobProgress } from "@/components/JobProgress";
|
||||||
import { adjustLabel } from "@/lib/labels";
|
import { adjustLabel } from "@/lib/labels";
|
||||||
|
|
||||||
export default function BacktestPage() {
|
export default function BacktestPage() {
|
||||||
@@ -61,9 +62,18 @@ function emptyDraft(range: { start: string; end: string }): Draft {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function draftToCombo(d: Draft): BacktestCombo {
|
/**
|
||||||
|
* 草稿 → 组合对象。
|
||||||
|
*
|
||||||
|
* `nameFallback`:**运行**(不保存)时组合名可以为空,但 `/combos/run` 的契约里
|
||||||
|
* `name` 必填 —— 老版本直接提交空名字,用户看到的是 422 原文
|
||||||
|
* (`String should have at least 1 character`),点了「运行」却像什么都没发生。
|
||||||
|
* 运行是临时组合、不会落库,所以这里给一个**如实说明「未命名/仅本次运行」**的名字,
|
||||||
|
* 而不是编一个像真的组合名。要正式命名请用「保存为回测组合」。
|
||||||
|
*/
|
||||||
|
function draftToCombo(d: Draft, nameFallback?: string): BacktestCombo {
|
||||||
return {
|
return {
|
||||||
name: d.name.trim(),
|
name: d.name.trim() || nameFallback || "",
|
||||||
description: d.description.trim(),
|
description: d.description.trim(),
|
||||||
strategy_ids: d.strategyIds,
|
strategy_ids: d.strategyIds,
|
||||||
initial_capital: d.initialCapital,
|
initial_capital: d.initialCapital,
|
||||||
@@ -132,9 +142,12 @@ function BacktestInner() {
|
|||||||
const [resultArchiveId, setResultArchiveId] = useState<string | null>(null);
|
const [resultArchiveId, setResultArchiveId] = useState<string | null>(null);
|
||||||
const [running, setRunning] = useState(false);
|
const [running, setRunning] = useState(false);
|
||||||
const [runningComboId, setRunningComboId] = useState<string | null>(null);
|
const [runningComboId, setRunningComboId] = useState<string | null>(null);
|
||||||
const [jobId, setJobId] = useState("");
|
/**
|
||||||
const [stage, setStage] = useState("");
|
* 作业反馈统一交给 `useJobRunner`(全站同一套):提交瞬间就有「正在提交作业…」,
|
||||||
const [elapsed, setElapsed] = useState(0);
|
* 之后是真实阶段 + 每秒自增的已用时间 + 作业号 + 可取消 —— 用户反馈的
|
||||||
|
* 「点了回测没有任何反馈,不知道有没有开始」就是这里的缺口。
|
||||||
|
*/
|
||||||
|
const job = useJobRunner<BacktestResult>("回测组合");
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
const [notice, setNotice] = useState("");
|
const [notice, setNotice] = useState("");
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
@@ -225,7 +238,13 @@ function BacktestInner() {
|
|||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await runVia(() => submitComboJob(draftToCombo(draft)), null);
|
// 未命名时用「未命名组合(仅本次运行)」:临时组合不落库,只是给作业一个可读标签;
|
||||||
|
// 不让用户在点「运行」后撞上 422(那正是「点了没反应」的观感来源之一)。
|
||||||
|
const fallback = draft.name.trim() ? undefined : "未命名组合(仅本次运行)";
|
||||||
|
if (fallback) {
|
||||||
|
setNotice("本次运行未命名组合名 —— 按「未命名组合(仅本次运行)」提交(不会保存到组合库)。");
|
||||||
|
}
|
||||||
|
await runVia(() => submitComboJob(draftToCombo(draft, fallback)), null);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -240,22 +259,24 @@ function BacktestInner() {
|
|||||||
setRunningComboId(comboId);
|
setRunningComboId(comboId);
|
||||||
setError("");
|
setError("");
|
||||||
setNotice("");
|
setNotice("");
|
||||||
setJobId("");
|
|
||||||
setResult(null);
|
setResult(null);
|
||||||
setResultArchiveId(null);
|
setResultArchiveId(null);
|
||||||
try {
|
try {
|
||||||
const { job_id } = await submit();
|
const out = await job.run(submit, {
|
||||||
setJobId(job_id);
|
label: draft.name ? `回测组合「${draft.name}」` : "回测组合",
|
||||||
setStage("queued");
|
|
||||||
const out = await waitJob<BacktestResult>(job_id, 900_000, (info) => {
|
|
||||||
setStage(info.stage);
|
|
||||||
setElapsed(info.elapsedMs);
|
|
||||||
});
|
});
|
||||||
if (out.status === "success" && out.result) {
|
if (out.status === "success" && out.result) {
|
||||||
setResult(out.result);
|
setResult(out.result as BacktestResult);
|
||||||
const exp = out.experimentId ?? null;
|
const exp = out.experimentId ?? null;
|
||||||
setResultArchiveId(exp);
|
setResultArchiveId(exp);
|
||||||
setNotice(exp ? `回测完成,已归档为实验 ${exp}(可在「实验对比」页与其它版本对比)。` : "回测完成,已自动归档。");
|
setNotice(
|
||||||
|
exp
|
||||||
|
? `回测完成,已归档为实验 ${exp}(可在「实验对比」页与其它版本对比)。`
|
||||||
|
: "回测完成,已自动归档。",
|
||||||
|
);
|
||||||
|
} else if (out.status === "cancelled") {
|
||||||
|
// 主动取消不是错误:如实说明,并提示可重新运行
|
||||||
|
setNotice("已取消该回测任务(未产生结果)。可改完参数后重新运行。");
|
||||||
} else {
|
} else {
|
||||||
setError(`任务${out.status}${out.error ? `:${out.error}` : ""}`);
|
setError(`任务${out.status}${out.error ? `:${out.error}` : ""}`);
|
||||||
}
|
}
|
||||||
@@ -264,7 +285,6 @@ function BacktestInner() {
|
|||||||
} finally {
|
} finally {
|
||||||
setRunning(false);
|
setRunning(false);
|
||||||
setRunningComboId(null);
|
setRunningComboId(null);
|
||||||
setJobId("");
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -626,23 +646,16 @@ function BacktestInner() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{running ? (
|
{/* 反馈条紧贴运行按钮:点了就有字,且在排队阶段就能取消 */}
|
||||||
<div className="stages" style={{ marginTop: 12 }}>
|
<JobProgress
|
||||||
{["data_loading", "backtesting", "analysis"].map((st) => {
|
state={job.state}
|
||||||
const order = ["queued", "data_loading", "backtesting", "analysis", "done"];
|
onCancel={job.cancel}
|
||||||
const cur = order.indexOf(stage);
|
onDismiss={job.reset}
|
||||||
const mine = order.indexOf(st);
|
anchorId="backtest-job-progress"
|
||||||
const cls = mine < cur ? "stage is-done" : mine === cur ? "stage is-active" : "stage";
|
/>
|
||||||
return (
|
{job.state.phase === "idle" && running ? (
|
||||||
<span className={cls} key={st}>
|
<div className="hint" style={{ marginTop: 12 }} role="status" aria-live="polite">
|
||||||
{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>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -0,0 +1,201 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 作业反馈条(全站统一)。
|
||||||
|
*
|
||||||
|
* 解决的问题(用户反馈原话):「点了回测没有任何反馈,不清楚是不是已经开始回测」。
|
||||||
|
* 因此这里有三条硬要求:
|
||||||
|
* 1. **点了立刻有字**:`submitting` 阶段就显示「正在提交作业…」,不等到后端返回;
|
||||||
|
* 2. **看得出在动**:作业号 + 真实阶段 + **每秒自增**的已用时间(阶段没变也在走);
|
||||||
|
* 3. **出事了能自救**:排队中可「取消」,失败显示后端原文,成功给「打开归档 / 去对比」。
|
||||||
|
*
|
||||||
|
* 反馈条出现时会自动滚到视野中央 —— 按钮常在长表单底部,反馈条在页面顶部,
|
||||||
|
* 不滚一下用户就看不见(这正是「没有反馈」错觉的来源之一)。
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useEffect, useRef } 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";
|
||||||
|
|
||||||
|
/** 已用时间:< 60s 显示秒,否则 mm:ss(跑几分钟时更好读) */
|
||||||
|
function fmtElapsed(ms: number): string {
|
||||||
|
const s = Math.max(0, Math.round(ms / 1000));
|
||||||
|
if (s < 60) return `${s}s`;
|
||||||
|
return `${Math.floor(s / 60)}m${String(s % 60).padStart(2, "0")}s`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtClock(d: Date | null): string {
|
||||||
|
if (!d) return "";
|
||||||
|
return d.toLocaleTimeString("zh-CN", { hour12: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function JobProgress<T>({
|
||||||
|
state,
|
||||||
|
onCancel,
|
||||||
|
onDismiss,
|
||||||
|
/** 完成后是否自动滚到视野(提交时恒滚,完成后不打断阅读) */
|
||||||
|
anchorId = "job-progress",
|
||||||
|
}: {
|
||||||
|
state: JobRunState<T>;
|
||||||
|
onCancel?: () => void;
|
||||||
|
onDismiss?: () => void;
|
||||||
|
anchorId?: string;
|
||||||
|
}) {
|
||||||
|
const ref = useRef<HTMLDivElement | null>(null);
|
||||||
|
const scrolled = useRef(false);
|
||||||
|
const active = state.phase === "submitting" || state.phase === "queued" || state.phase === "running";
|
||||||
|
|
||||||
|
// 提交瞬间(含同一页面第二次提交)把反馈条滚进视野:按钮在长表单底部时,
|
||||||
|
// 顶部出现的反馈条不滚过去就等于没显示。
|
||||||
|
useEffect(() => {
|
||||||
|
if (state.phase === "idle") {
|
||||||
|
scrolled.current = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if ((state.phase === "submitting" || state.phase === "queued") && !scrolled.current) {
|
||||||
|
scrolled.current = true;
|
||||||
|
ref.current?.scrollIntoView({ block: "center", behavior: "smooth" });
|
||||||
|
}
|
||||||
|
}, [state.phase]);
|
||||||
|
|
||||||
|
if (state.phase === "idle") return null;
|
||||||
|
|
||||||
|
const stageText = state.stage
|
||||||
|
? state.stage === "cancelling"
|
||||||
|
? "正在取消…"
|
||||||
|
: (STAGE_LABEL[state.stage] ?? state.stage)
|
||||||
|
: "等待后端回报阶段";
|
||||||
|
const cur = STAGE_ORDER.indexOf(state.stage);
|
||||||
|
const archiveHref = state.experimentId
|
||||||
|
? `/experiments?exp=${encodeURIComponent(state.experimentId)}`
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const tone =
|
||||||
|
state.phase === "success" ? "job-progress--ok" : state.phase === "failed" || state.phase === "timeout" ? "job-progress--bad" : "job-progress--run";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
id={anchorId}
|
||||||
|
className={`job-progress ${tone}`}
|
||||||
|
role="status"
|
||||||
|
aria-live="polite"
|
||||||
|
data-phase={state.phase}
|
||||||
|
>
|
||||||
|
<div className="job-progress__head">
|
||||||
|
{active ? (
|
||||||
|
<Pill tone="accent" icon="spinner">
|
||||||
|
{state.phase === "submitting" ? "正在提交作业…" : stageText}
|
||||||
|
</Pill>
|
||||||
|
) : state.phase === "success" ? (
|
||||||
|
<Pill tone="pos" icon="check">
|
||||||
|
已完成
|
||||||
|
</Pill>
|
||||||
|
) : state.phase === "cancelled" ? (
|
||||||
|
<Pill tone="warn" icon="x">
|
||||||
|
已取消
|
||||||
|
</Pill>
|
||||||
|
) : (
|
||||||
|
<Pill tone="neg" icon="alert">
|
||||||
|
{state.phase === "timeout" ? "等待超时" : "执行失败"}
|
||||||
|
</Pill>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<span className="job-progress__title">
|
||||||
|
{state.label || "研究任务"}
|
||||||
|
{state.progress ? (
|
||||||
|
<span className="hint">
|
||||||
|
{" "}
|
||||||
|
(第 {state.progress.index}/{state.progress.total} 个)
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<span className="job-progress__meta">
|
||||||
|
{state.jobId ? (
|
||||||
|
<>
|
||||||
|
作业 <span className="mono">{state.jobId}</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<span className="hint">正在换取作业号…</span>
|
||||||
|
)}
|
||||||
|
{state.startedAt ? <span className="hint">发起于 {fmtClock(state.startedAt)}</span> : null}
|
||||||
|
{active ? <span className="mono">已用 {fmtElapsed(state.elapsedMs)}</span> : null}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<span className="job-progress__actions">
|
||||||
|
{active && onCancel && state.jobId ? (
|
||||||
|
<Btn size="sm" icon="x" loading={state.cancelling} disabled={state.cancelling} onClick={onCancel}>
|
||||||
|
取消任务
|
||||||
|
</Btn>
|
||||||
|
) : null}
|
||||||
|
{!active && archiveHref ? (
|
||||||
|
<Link className="btn btn--sm" href={archiveHref}>
|
||||||
|
<span>去对比</span>
|
||||||
|
</Link>
|
||||||
|
) : null}
|
||||||
|
{!active && state.experimentId ? (
|
||||||
|
<Link
|
||||||
|
className="btn btn--sm"
|
||||||
|
href={`/experiments/${encodeURIComponent(state.experimentId)}`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
>
|
||||||
|
<span>打开归档</span>
|
||||||
|
<Icon name="arrowUpRight" size={13} />
|
||||||
|
</Link>
|
||||||
|
) : null}
|
||||||
|
{!active && onDismiss ? (
|
||||||
|
<Btn size="sm" onClick={onDismiss}>
|
||||||
|
收起
|
||||||
|
</Btn>
|
||||||
|
) : null}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{active ? (
|
||||||
|
<div className="stages" style={{ marginTop: 8 }}>
|
||||||
|
{["data_loading", "selection", "backtesting", "analysis"].map((st) => {
|
||||||
|
const mine = STAGE_ORDER.indexOf(st);
|
||||||
|
const cls = cur >= 0 && mine < cur ? "stage is-done" : mine === cur ? "stage is-active" : "stage";
|
||||||
|
return (
|
||||||
|
<span className={cls} key={st}>
|
||||||
|
{cur >= 0 && mine < cur ? "✓" : mine === cur ? "●" : "○"} {STAGE_LABEL[st] ?? st}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{active ? (
|
||||||
|
<div className="hint" style={{ marginTop: 6 }}>
|
||||||
|
后台执行中:可以离开本页(刷新 / 关页都不影响),跑完会自动归档到「实验对比」。
|
||||||
|
全市场多年区间通常 3~5 分钟;阶段与已用时间来自后端真实状态,不是假进度条。
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{state.phase === "failed" || state.phase === "timeout" ? (
|
||||||
|
<div className="job-progress__err" role="alert">
|
||||||
|
{state.error || "任务失败,后端没有给出原因。"}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{state.phase === "cancelled" ? (
|
||||||
|
<div className="hint" style={{ marginTop: 6 }}>
|
||||||
|
任务已取消,没有归档(取消的任务不产生实验记录)。可以改完参数再跑一次。
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{state.phase === "success" ? (
|
||||||
|
<div className="hint" style={{ marginTop: 6 }}>
|
||||||
|
{state.experimentId
|
||||||
|
? `已归档为实验 ${state.experimentId},可在「实验对比」里与其它版本叠曲线、比参数。`
|
||||||
|
: "已完成(本次没有归档,可能是同步任务)。"}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3,6 +3,8 @@
|
|||||||
* 大样本研究(全市场)可能耗时数十秒到分钟级,经异步 Job 后台执行,
|
* 大样本研究(全市场)可能耗时数十秒到分钟级,经异步 Job 后台执行,
|
||||||
* 避免 HTTP 长阻塞(AGENT §19)。页面提交后即时返回 job_id,再轮询到终态。
|
* 避免 HTTP 长阻塞(AGENT §19)。页面提交后即时返回 job_id,再轮询到终态。
|
||||||
*/
|
*/
|
||||||
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
|
||||||
import { apiGet, apiPost } from "./api";
|
import { apiGet, apiPost } from "./api";
|
||||||
import type { ResearchSpec } from "./types";
|
import type { ResearchSpec } from "./types";
|
||||||
|
|
||||||
@@ -75,6 +77,19 @@ export const STAGE_LABEL: Record<string, string> = {
|
|||||||
done: "完成",
|
done: "完成",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** 执行阶段时间线(顺序即推进顺序,用于「阶段小圆点」) */
|
||||||
|
export const STAGE_ORDER = ["queued", "data_loading", "selection", "backtesting", "analysis", "done"];
|
||||||
|
|
||||||
|
/** 取消排队中/执行中的作业(POST /api/jobs/{id}/cancel;已结束的会返回 cancelled=false)。 */
|
||||||
|
export function cancelJob(
|
||||||
|
jobId: string,
|
||||||
|
): Promise<{ job_id: string; status: string; cancelled: boolean }> {
|
||||||
|
return apiPost<{ job_id: string; status: string; cancelled: boolean }>(
|
||||||
|
`/jobs/${encodeURIComponent(jobId)}/cancel`,
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
/** 提交一个回测组合为异步 Job(POST /api/combos/run,不保存组合)。 */
|
/** 提交一个回测组合为异步 Job(POST /api/combos/run,不保存组合)。 */
|
||||||
export async function submitComboJob(combo: unknown): Promise<JobSubmit> {
|
export async function submitComboJob(combo: unknown): Promise<JobSubmit> {
|
||||||
@@ -90,3 +105,204 @@ export async function submitComboJob(combo: unknown): Promise<JobSubmit> {
|
|||||||
export async function runSavedCombo(comboId: string): Promise<JobSubmit> {
|
export async function runSavedCombo(comboId: string): Promise<JobSubmit> {
|
||||||
return apiPost<JobSubmit>(`/combos/${encodeURIComponent(comboId)}/run`, {});
|
return apiPost<JobSubmit>(`/combos/${encodeURIComponent(comboId)}/run`, {});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ *
|
||||||
|
* 统一的「提交 → 轮询 → 终态」状态机(useJobRunner)
|
||||||
|
*
|
||||||
|
* 为什么要有它:以前每个页面各写一遍 running/jobId/error,结果参差不齐 ——
|
||||||
|
* 有的页面点了「运行」只在按钮上转圈,用户不知道到底提交没提交、跑到哪一步;
|
||||||
|
* 有的页面「已用秒数」只在后端阶段变化时才更新,看起来像卡死了(0s 一动不动)。
|
||||||
|
* 这里把状态、秒表、取消、终态收敛到一处,页面只管渲染。
|
||||||
|
* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
export type JobPhase =
|
||||||
|
| "idle"
|
||||||
|
| "submitting"
|
||||||
|
| "queued"
|
||||||
|
| "running"
|
||||||
|
| "success"
|
||||||
|
| "failed"
|
||||||
|
| "cancelled"
|
||||||
|
| "timeout";
|
||||||
|
|
||||||
|
export interface JobRunState<T> {
|
||||||
|
phase: JobPhase;
|
||||||
|
/** 作业号(提交成功后立即拿到;这是「到底提交没提交」的凭据) */
|
||||||
|
jobId: string;
|
||||||
|
/** 后端上报的执行阶段(queued/data_loading/selection/backtesting/analysis/done) */
|
||||||
|
stage: string;
|
||||||
|
/** 已用毫秒:**每秒自增**,不依赖后端阶段变化,所以界面不会看起来卡住 */
|
||||||
|
elapsedMs: number;
|
||||||
|
/** 这次跑的是什么(如「因子测试 · momentum_60」),显示在反馈条上 */
|
||||||
|
label: string;
|
||||||
|
/** 批量场景的进度(第 index / total 个),单跑时为 null */
|
||||||
|
progress: { index: number; total: number } | null;
|
||||||
|
error: string;
|
||||||
|
result: T | null;
|
||||||
|
experimentId: string | null;
|
||||||
|
/** 提交时刻(本地时间,用于「发起于 hh:mm:ss」) */
|
||||||
|
startedAt: Date | null;
|
||||||
|
/** 正在请求取消 */
|
||||||
|
cancelling: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function emptyState<T>(label = ""): JobRunState<T> {
|
||||||
|
return {
|
||||||
|
phase: "idle",
|
||||||
|
jobId: "",
|
||||||
|
stage: "",
|
||||||
|
elapsedMs: 0,
|
||||||
|
label,
|
||||||
|
progress: null,
|
||||||
|
error: "",
|
||||||
|
result: null,
|
||||||
|
experimentId: null,
|
||||||
|
startedAt: null,
|
||||||
|
cancelling: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface JobRunOptions {
|
||||||
|
/** 反馈条上显示的任务名(如「因子测试 · momentum_60」) */
|
||||||
|
label?: string;
|
||||||
|
/** 批量进度(第 index / total 个) */
|
||||||
|
progress?: { index: number; total: number } | null;
|
||||||
|
timeoutMs?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface JobRunner<T> {
|
||||||
|
state: JobRunState<T>;
|
||||||
|
/** 提交并轮询到终态;返回终态结果(供页面继续处理,如渲染报告) */
|
||||||
|
run: (
|
||||||
|
submit: () => Promise<{ job_id: string }>,
|
||||||
|
opts?: JobRunOptions,
|
||||||
|
) => Promise<JobOutcome<T>>;
|
||||||
|
cancel: () => Promise<void>;
|
||||||
|
reset: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 作业运行状态机。
|
||||||
|
*
|
||||||
|
* 生命周期:idle → submitting(已点击,正在换 job_id)→ queued/running(轮询中,秒表走)
|
||||||
|
* → success | failed | cancelled | timeout。**submitting 也是状态**:点了按钮立刻就有
|
||||||
|
* 反馈文字,不会出现「点了没反应」的空白期。
|
||||||
|
*/
|
||||||
|
export function useJobRunner<T>(defaultLabel = ""): JobRunner<T> {
|
||||||
|
const [state, setState] = useState<JobRunState<T>>(() => emptyState<T>(defaultLabel));
|
||||||
|
const t0 = useRef(0);
|
||||||
|
const timer = useRef<number | null>(null);
|
||||||
|
const alive = useRef(true);
|
||||||
|
const currentJob = useRef("");
|
||||||
|
// 后端最近上报的阶段放在 ref 里:终态那一帧要写「最后一个阶段」,但不想让
|
||||||
|
// `run` 依赖 state(否则每次 state 变化都换一个新函数,页面 effect 会连环触发)。
|
||||||
|
const stageRef = useRef("");
|
||||||
|
|
||||||
|
const stopTick = useCallback(() => {
|
||||||
|
if (timer.current !== null) {
|
||||||
|
window.clearInterval(timer.current);
|
||||||
|
timer.current = null;
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(
|
||||||
|
() => () => {
|
||||||
|
alive.current = false;
|
||||||
|
if (timer.current !== null) window.clearInterval(timer.current);
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const patch = useCallback((next: Partial<JobRunState<T>>) => {
|
||||||
|
if (!alive.current) return;
|
||||||
|
setState((prev) => ({ ...prev, ...next }));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const reset = useCallback(() => {
|
||||||
|
stopTick();
|
||||||
|
currentJob.current = "";
|
||||||
|
if (alive.current) setState(emptyState<T>(defaultLabel));
|
||||||
|
}, [defaultLabel, stopTick]);
|
||||||
|
|
||||||
|
const run = useCallback(
|
||||||
|
async (
|
||||||
|
submit: () => Promise<{ job_id: string }>,
|
||||||
|
opts: JobRunOptions = {},
|
||||||
|
): Promise<JobOutcome<T>> => {
|
||||||
|
stopTick();
|
||||||
|
currentJob.current = "";
|
||||||
|
stageRef.current = "";
|
||||||
|
t0.current = Date.now();
|
||||||
|
setState({
|
||||||
|
...emptyState<T>(opts.label ?? defaultLabel),
|
||||||
|
phase: "submitting",
|
||||||
|
label: opts.label ?? defaultLabel,
|
||||||
|
progress: opts.progress ?? null,
|
||||||
|
startedAt: new Date(),
|
||||||
|
});
|
||||||
|
// 秒表独立于后端阶段:即使后端几秒内不报新阶段,「已用 Xs」也在动
|
||||||
|
timer.current = window.setInterval(() => {
|
||||||
|
if (!alive.current) return;
|
||||||
|
setState((prev) =>
|
||||||
|
prev.phase === "submitting" || prev.phase === "queued" || prev.phase === "running"
|
||||||
|
? { ...prev, elapsedMs: Date.now() - t0.current }
|
||||||
|
: prev,
|
||||||
|
);
|
||||||
|
}, 500);
|
||||||
|
try {
|
||||||
|
const { job_id } = await submit();
|
||||||
|
currentJob.current = job_id;
|
||||||
|
stageRef.current = "queued";
|
||||||
|
patch({ jobId: job_id, phase: "queued", stage: "queued" });
|
||||||
|
const out = await waitJob<T>(job_id, opts.timeoutMs ?? 900_000, (info) => {
|
||||||
|
stageRef.current = info.stage;
|
||||||
|
patch({
|
||||||
|
stage: info.stage,
|
||||||
|
elapsedMs: info.elapsedMs,
|
||||||
|
phase: info.stage && info.stage !== "queued" ? "running" : "queued",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
const finalPhase: JobPhase =
|
||||||
|
out.status === "success"
|
||||||
|
? "success"
|
||||||
|
: out.status === "cancelled"
|
||||||
|
? "cancelled"
|
||||||
|
: out.status === "timeout"
|
||||||
|
? "timeout"
|
||||||
|
: "failed";
|
||||||
|
patch({
|
||||||
|
phase: finalPhase,
|
||||||
|
stage: out.status === "success" ? "done" : stageRef.current,
|
||||||
|
elapsedMs: Date.now() - t0.current,
|
||||||
|
result: out.result,
|
||||||
|
experimentId: out.experimentId ?? null,
|
||||||
|
error: out.error ?? "",
|
||||||
|
});
|
||||||
|
return out;
|
||||||
|
} catch (e) {
|
||||||
|
const msg = (e as Error).message;
|
||||||
|
patch({ phase: "failed", error: msg, elapsedMs: Date.now() - t0.current });
|
||||||
|
return { status: "failed", result: null, error: msg };
|
||||||
|
} finally {
|
||||||
|
stopTick();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[defaultLabel, patch, stopTick],
|
||||||
|
);
|
||||||
|
|
||||||
|
const cancel = useCallback(async () => {
|
||||||
|
const jobId = currentJob.current;
|
||||||
|
if (!jobId) return;
|
||||||
|
patch({ cancelling: true });
|
||||||
|
try {
|
||||||
|
await cancelJob(jobId);
|
||||||
|
// 取消后轮询会在 1.5s 内看到 cancelled 终态;这里先把阶段文字改掉,
|
||||||
|
// 让用户马上知道「取消请求已发出」而不是等下一个轮询周期。
|
||||||
|
patch({ stage: "cancelling" });
|
||||||
|
} finally {
|
||||||
|
patch({ cancelling: false });
|
||||||
|
}
|
||||||
|
}, [patch]);
|
||||||
|
|
||||||
|
return { state, run, cancel, reset };
|
||||||
|
}
|
||||||
|
|||||||
@@ -234,6 +234,7 @@ export const REASON_LABELS: Record<string, string> = {
|
|||||||
buy_skip_min_commission: "不足最低佣金",
|
buy_skip_min_commission: "不足最低佣金",
|
||||||
sell_drop_topn: "跌出 TopN",
|
sell_drop_topn: "跌出 TopN",
|
||||||
sell_force_tmax: "持有超 Tmax",
|
sell_force_tmax: "持有超 Tmax",
|
||||||
|
sell_rebalance_full: "调仓换仓卖出",
|
||||||
sell_defer_tmin: "Tmin 保护暂留",
|
sell_defer_tmin: "Tmin 保护暂留",
|
||||||
sell_defer_halted: "停牌未卖",
|
sell_defer_halted: "停牌未卖",
|
||||||
sell_defer_limit_down: "跌停未卖",
|
sell_defer_limit_down: "跌停未卖",
|
||||||
|
|||||||
Reference in New Issue
Block a user