feat(job): D2 全市场选股 Job 化(kind=selection 异步)

- Job kind=selection:executor 分支用 SelectionService(spec=SelectionQuery),结果
  落 result_json、自动归档 Experiment(summary:as_of + 选出 N);/api/jobs 与
  /api/experiments 的 result 解码支持 SelectionResult
- POST /api/selections/jobs:提交 SelectionQuery 为异步 Job(BackgroundTasks/subprocess)
- Web 选股页新增「异步(全市场)」按钮:提交 Job → waitJob 轮询结果(解决同步 60s+)
- tests/test_selection_job.py:executor 执行归档(SelectionResult/Experiment kind)、
  API 提交→轮询→结果与实验列表;全量 pytest + tsc 通过
This commit is contained in:
Simon
2026-09-09 07:43:35 +08:00
parent ed54096331
commit 861a4051ca
6 changed files with 211 additions and 13 deletions
+28 -1
View File
@@ -6,6 +6,7 @@
*/
import { useEffect, useState } from "react";
import { apiGet, apiPost } from "@/lib/api";
import { waitJob } from "@/lib/jobs";
import type {
FactorMeta,
SelectionCondition,
@@ -53,6 +54,7 @@ export default function SelectionPage() {
const [result, setResult] = useState<SelectionResult | null>(null);
const [selectionId, setSelectionId] = useState("");
const [running, setRunning] = useState(false);
const [asyncBusy, setAsyncBusy] = useState(false);
const [error, setError] = useState("");
const [history, setHistory] = useState<SelectionMeta[] | null>(null);
@@ -111,6 +113,28 @@ export default function SelectionPage() {
}
}
/** 异步(全市场等长任务):提交 Job 后台执行并轮询(解决同步 60s+)。 */
async function runAsync() {
setAsyncBusy(true);
setError("");
setResult(null);
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);
}
}
async function openHistory(id: string) {
setError("");
try {
@@ -279,9 +303,12 @@ export default function SelectionPage() {
)}
<div style={{ marginTop: 14 }}>
<Btn variant="primary" icon="play" loading={running} disabled={running} onClick={run}>
<Btn variant="primary" icon="play" loading={running} disabled={running || asyncBusy} onClick={run}>
{running ? "筛选中…" : "执行选股"}
</Btn>
<Btn variant="ghost" icon="clock" loading={asyncBusy} disabled={running || asyncBusy} onClick={runAsync}>
{asyncBusy ? "后台执行中…" : "异步(全市场)"}
</Btn>
</div>
{error ? (
<div style={{ marginTop: 12 }}>