diff --git a/frontend/web/app/backtest/page.tsx b/frontend/web/app/backtest/page.tsx index f765dea..13a018e 100644 --- a/frontend/web/app/backtest/page.tsx +++ b/frontend/web/app/backtest/page.tsx @@ -1,7 +1,8 @@ "use client"; import { useEffect, useState } from "react"; -import { apiGet, apiPost } from "@/lib/api"; +import { apiGet } from "@/lib/api"; +import { submitJob, waitJob } from "@/lib/jobs"; import type { BacktestResult, FactorMeta, ResearchSpec } from "@/lib/types"; import { LineChart } from "@/components/LineChart"; @@ -15,6 +16,7 @@ export default function BacktestPage() { const [excludeSt, setExcludeSt] = useState(true); const [result, setResult] = useState(null); const [running, setRunning] = useState(false); + const [jobId, setJobId] = useState(""); const [error, setError] = useState(""); useEffect(() => { @@ -23,21 +25,34 @@ export default function BacktestPage() { .catch((e: Error) => setError(e.message)); }, []); - function run() { + async function run() { setRunning(true); setError(""); - const spec: ResearchSpec = { - type: "backtest", - universe: { exclude_st: excludeSt, min_listing_days: 0 }, - factors: [{ name: factor, weight: 1 }], - selection: { top_n: topN }, - rebalance, - period: [start, end], - }; - apiPost("/backtests", spec) - .then(setResult) - .catch((e: Error) => setError(e.message)) - .finally(() => setRunning(false)); + setJobId(""); + setResult(null); + try { + const spec: ResearchSpec = { + type: "backtest", + universe: { exclude_st: excludeSt, min_listing_days: 0 }, + factors: [{ name: factor, weight: 1 }], + selection: { top_n: topN }, + rebalance, + period: [start, end], + }; + // 异步 Job:后台执行(全市场可能数十秒),轮询到终态 + const { job_id } = await submitJob(spec); + setJobId(job_id); + const out = await waitJob(job_id); + if (out.status === "success" && out.result) { + setResult(out.result); + } else { + setError(`任务${out.status}${out.error ? `:${out.error}` : ""}`); + } + } catch (e) { + setError((e as Error).message); + } finally { + setRunning(false); + } } const s = result?.summary; @@ -94,9 +109,10 @@ export default function BacktestPage() { 剔除 ST + {running && jobId &&
任务 {jobId} 后台执行中,请稍候…
} {error &&
{error}
} diff --git a/frontend/web/app/factors/page.tsx b/frontend/web/app/factors/page.tsx index 81a349c..58fd112 100644 --- a/frontend/web/app/factors/page.tsx +++ b/frontend/web/app/factors/page.tsx @@ -1,7 +1,8 @@ "use client"; import { useEffect, useState } from "react"; -import { apiGet, apiPost } from "@/lib/api"; +import { apiGet } from "@/lib/api"; +import { submitJob, waitJob } from "@/lib/jobs"; import type { FactorMeta, FactorTestReport, ResearchSpec } from "@/lib/types"; export default function FactorsPage() { @@ -11,6 +12,7 @@ export default function FactorsPage() { const [end, setEnd] = useState("2024-12-31"); const [report, setReport] = useState(null); const [running, setRunning] = useState(false); + const [jobId, setJobId] = useState(""); const [error, setError] = useState(""); useEffect(() => { @@ -19,21 +21,34 @@ export default function FactorsPage() { .catch((e: Error) => setError(e.message)); }, []); - function run() { + async function run() { setRunning(true); setError(""); - 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], - }; - apiPost("/factor-tests", spec) - .then(setReport) - .catch((e: Error) => setError(e.message)) - .finally(() => setRunning(false)); + setJobId(""); + setReport(null); + try { + 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], + }; + // 异步 Job:立即返回 job_id,后台执行后轮询取结果(全市场可能数十秒) + const { job_id } = await submitJob(spec); + setJobId(job_id); + const out = await waitJob(job_id); + if (out.status === "success" && out.result) { + setReport(out.result); + } else { + setError(`任务${out.status}${out.error ? `:${out.error}` : ""}`); + } + } catch (e) { + setError((e as Error).message); + } finally { + setRunning(false); + } } return ( @@ -82,9 +97,10 @@ export default function FactorsPage() { 结束 setEnd(e.target.value)} /> + {running && jobId &&
任务 {jobId} 后台执行中,请稍候…
} {error &&
{error}
} {report && ( diff --git a/frontend/web/lib/jobs.ts b/frontend/web/lib/jobs.ts new file mode 100644 index 0000000..56ff83e --- /dev/null +++ b/frontend/web/lib/jobs.ts @@ -0,0 +1,43 @@ +/** 异步 Job 研究执行(Phase 4):POST /api/jobs 提交 → 轮询 GET /api/jobs/{id}。 + * + * 大样本研究(全市场)可能耗时数十秒到分钟级,经异步 Job 后台执行, + * 避免 HTTP 长阻塞(AGENT §19)。页面提交后即时返回 job_id,再轮询到终态。 + */ +import { apiGet, apiPost } from "./api"; +import type { ResearchSpec } from "./types"; + +export interface JobSubmit { + job_id: string; + status: string; +} + +export interface JobStatusResp { + job_id: string; + status: string; + result?: unknown; + error?: string | null; + spec?: Record; +} + +export function submitJob(spec: ResearchSpec): Promise { + return apiPost("/jobs", spec); +} + +/** 轮询直到 success / failed / cancelled,或超时(默认 10 分钟)。 */ +export async function waitJob( + jobId: string, + timeoutMs = 600_000, +): Promise<{ status: string; result: T | null; error?: string }> { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + const job = await apiGet(`/jobs/${jobId}`); + if (job.status === "success") { + return { status: job.status, result: (job.result as T) ?? null }; + } + if (job.status === "failed" || job.status === "cancelled") { + return { status: job.status, result: null, error: job.error ?? undefined }; + } + await new Promise((resolve) => setTimeout(resolve, 1500)); + } + return { status: "timeout", result: null, error: "等待结果超时,请稍后在「实验」页查看归档" }; +}