Files
qlib/frontend/web/app/selection/page.tsx
T
Simon 861a4051ca 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 通过
2026-09-09 07:43:35 +08:00

415 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
/** 股票筛选(选股系统 M6,v2 §14/§21.1):
* 评分模式(多因子加权 TopN)与条件模式(结构化条件 AND);
* 支持当前/历史 as_of;结果落库可查(selection_id),右侧展示最近选股历史。
*/
import { useEffect, useState } from "react";
import { apiGet, apiPost } from "@/lib/api";
import { waitJob } from "@/lib/jobs";
import type {
FactorMeta,
SelectionCondition,
SelectionMeta,
SelectionQuery,
SelectionResult,
SelectionRun,
} from "@/lib/types";
import {
PageHeader,
Card,
Pill,
Field,
Btn,
Banner,
Empty,
SkeletonLines,
} from "@/components/ui";
const FIELD_OPTIONS: { value: string; label: string; kind: "num" | "str" | "ref" }[] = [
{ value: "static.industry", label: "行业 industry", kind: "str" },
{ value: "static.market", label: "市场 market", kind: "str" },
{ value: "momentum_20", label: "动量 20 日", kind: "num" },
{ value: "momentum_60", label: "动量 60 日", kind: "num" },
{ value: "close", label: "收盘价 close", kind: "num" },
{ value: "close_ma60_ref", label: "收盘 > MA60", kind: "ref" },
{ value: "volume_ratio_5_60", label: "量比", kind: "num" },
{ value: "fundamental.roe", label: "ROE(%)", kind: "num" },
{ value: "fundamental.eps", label: "EPS", kind: "num" },
];
const OPS = ["gt", "gte", "lt", "lte", "eq", "ne", "in"];
function emptyCondition(): SelectionCondition {
return { field: "static.industry", op: "eq", value: "白酒" };
}
export default function SelectionPage() {
const [factors, setFactors] = useState<FactorMeta[]>([]);
const [mode, setMode] = useState<"score" | "condition">("score");
const [asOf, setAsOf] = useState(""); // 空 = 最近交易日
const [excludeSt, setExcludeSt] = useState(true);
const [topN, setTopN] = useState(20);
const [factorRows, setFactorRows] = useState([{ name: "momentum_60", weight: 1 }]);
const [conds, setConds] = useState<SelectionCondition[]>([emptyCondition()]);
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);
useEffect(() => {
let alive = true;
apiGet<FactorMeta[]>("/factors")
.then((list) => alive && list.length > 0 && setFactors(list))
.catch((e: Error) => alive && setError(e.message));
apiGet<SelectionMeta[]>("/selections?limit=8")
.then((rows) => alive && setHistory(rows))
.catch(() => alive && setHistory([]));
return () => {
alive = false;
};
}, []);
function buildQuery(): SelectionQuery {
if (mode === "score") {
return {
universe: { exclude_st: excludeSt, min_listing_days: 0 },
as_of: asOf || null,
method: "score",
factors: factorRows.filter((r) => r.name),
conditions: [],
top_n: topN,
};
}
return {
universe: { exclude_st: excludeSt, min_listing_days: 0 },
as_of: asOf || null,
method: "condition",
factors: [],
conditions: conds.map((c) => ({
...c,
...(c.field === "close_ma60_ref"
? { field: "close", op: c.op, ref: "ma60", value: null }
: {}),
})),
top_n: null,
};
}
async function run() {
setRunning(true);
setError("");
setResult(null);
try {
const run = await apiPost<SelectionRun>("/selections", buildQuery());
setSelectionId(run.selection_id);
setResult(run.result);
apiGet<SelectionMeta[]>("/selections?limit=8").then(setHistory).catch(() => undefined);
} catch (e) {
setError((e as Error).message);
} finally {
setRunning(false);
}
}
/** 异步(全市场等长任务):提交 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 {
const detail = await apiGet<SelectionResult>(`/selections/${id}`);
setSelectionId(id);
setResult(detail);
} catch (e) {
setError((e as Error).message);
}
}
return (
<>
<PageHeader
title="股票筛选"
sub="以因子评分 TopN 或结构化条件筛选股票,支持当前 / 历史时点;每次选股落库可复现、可解释(为什么选它)。"
actions={
selectionId ? (
<Pill tone="violet" icon="check">
{selectionId}
</Pill>
) : null
}
/>
<Card title="选股条件" icon="filter">
<div className="form-grid">
<Field label="方式">
<select
className="input"
value={mode}
onChange={(e) => setMode(e.target.value as "score" | "condition")}
>
<option value="score">因子评分(TopN)</option>
<option value="condition">条件筛选(AND)</option>
</select>
</Field>
<Field label="选股时点 as_of" hint="留空 = 最近可用交易日">
<input type="date" className="input" value={asOf} onChange={(e) => setAsOf(e.target.value)} />
</Field>
<Field label="标的范围">
<label className="row" style={{ gap: 6, color: "var(--text-2)", fontSize: 13, cursor: "pointer" }}>
<input type="checkbox" checked={excludeSt} onChange={(e) => setExcludeSt(e.target.checked)} />
剔除 ST
</label>
</Field>
</div>
{mode === "score" ? (
<>
<div style={{ fontSize: 13, color: "var(--text-2)", margin: "10px 0 6px" }}>评分因子与权重</div>
{factorRows.map((r, i) => (
<div className="row" style={{ gap: 8, marginBottom: 8 }} key={i}>
<select
className="input"
style={{ flex: 2 }}
value={r.name}
onChange={(e) => {
const next = [...factorRows];
next[i] = { ...next[i], name: e.target.value };
setFactorRows(next);
}}
>
{factors.map((f) => (
<option key={f.name} value={f.name}>{f.name}</option>
))}
</select>
<input
className="input"
style={{ flex: 1 }}
type="number"
step="0.1"
value={r.weight}
onChange={(e) => {
const next = [...factorRows];
next[i] = { ...next[i], weight: Number(e.target.value) };
setFactorRows(next);
}}
/>
<button
className="btn"
disabled={factorRows.length <= 1}
onClick={() => setFactorRows(factorRows.filter((_, j) => j !== i))}
>
删除
</button>
</div>
))}
<div className="row" style={{ gap: 10 }}>
<button className="btn" onClick={() => setFactorRows([...factorRows, { name: "momentum_60", weight: 1 }])}>
+ 因子
</button>
<Field label="Top N">
<input className="input" type="number" min={1} value={topN} onChange={(e) => setTopN(Number(e.target.value))} />
</Field>
</div>
</>
) : (
<>
<div style={{ fontSize: 13, color: "var(--text-2)", margin: "10px 0 6px" }}>条件(全部满足才入选)</div>
{conds.map((c, i) => {
const meta = FIELD_OPTIONS.find((f) => f.value === (c.field === "close" && c.ref ? "close_ma60_ref" : c.field));
const isRef = c.field === "close" && c.ref === "ma60";
return (
<div className="row" style={{ gap: 8, marginBottom: 8, flexWrap: "wrap" }} key={i}>
<select
className="input"
style={{ flex: 2 }}
value={isRef ? "close_ma60_ref" : c.field}
onChange={(e) => {
const next = [...conds];
const v = e.target.value;
next[i] = v === "close_ma60_ref"
? { field: "close", op: "gt", ref: "ma60", value: null }
: { field: v, op: c.op, value: v.startsWith("static.") ? "" : 0 };
setConds(next);
}}
>
{FIELD_OPTIONS.map((f) => (
<option key={f.value} value={f.value}>{f.label}</option>
))}
</select>
<select
className="input"
style={{ flex: 1 }}
value={c.op}
onChange={(e) => {
const next = [...conds];
next[i] = { ...next[i], op: e.target.value as SelectionCondition["op"] };
setConds(next);
}}
>
{OPS.map((o) => <option key={o} value={o}>{o}</option>)}
</select>
{!isRef ? (
<input
className="input"
style={{ flex: 1 }}
placeholder={c.op === "in" ? "逗号分隔多个值" : "值"}
value={String(c.value ?? "")}
onChange={(e) => {
const next = [...conds];
const raw = e.target.value;
const val = c.op === "in"
? raw.split(/[,,]/).map((s) => s.trim()).filter(Boolean)
: (meta?.kind === "num" && !c.field.startsWith("static.")
? Number(raw)
: raw);
next[i] = { ...next[i], value: val as never };
setConds(next);
}}
/>
) : null}
<button
className="btn"
disabled={conds.length <= 1}
onClick={() => setConds(conds.filter((_, j) => j !== i))}
>
删除
</button>
</div>
);
})}
<button className="btn" onClick={() => setConds([...conds, emptyCondition()])}>+ 条件</button>
</>
)}
<div style={{ marginTop: 14 }}>
<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 }}>
<Banner tone="error">{error}</Banner>
</div>
) : null}
</Card>
{!result && !running && history === null ? <SkeletonLines n={4} /> : null}
{result ? <ResultView result={result} /> : null}
{history && history.length > 0 ? (
<Card title="最近选股记录" icon="archive">
<div className="chips">
{history.map((m) => (
<button
key={m.id}
className="chip"
style={{ cursor: "pointer" }}
onClick={() => openHistory(m.id)}
title={`${m.id} · ${m.method} · ${m.selected} 只`}
>
<b className="mono">{m.as_of}</b>
{m.method} · {m.selected} 只
</button>
))}
</div>
</Card>
) : null}
</>
);
}
function ResultView({ result }: { result: SelectionResult }) {
const s = result.statistics;
if (result.candidates.length === 0) {
return (
<Card>
<Empty
icon="filter"
title="无符合条件/可评分的股票"
hint={`范围 ${s.universe_size} 只,可评分 ${s.evaluated} 只。可放宽条件或选择更早的 as_of。`}
/>
</Card>
);
}
return (
<Card
icon="target"
title={`选股结果 · ${result.as_of_date} · ${result.method}`}
tools={
<>
<Pill>范围 {s.universe_size}</Pill>
<Pill>评分 {s.evaluated}</Pill>
<Pill tone="pos">选出 {s.selected}</Pill>
</>
}
>
<div className="table-wrap">
<table className="tbl">
<thead>
<tr>
<th>#</th>
<th>代码</th>
<th>得分</th>
<th>因子值</th>
<th>入选理由</th>
</tr>
</thead>
<tbody>
{result.candidates.map((c) => (
<tr key={c.symbol}>
<td className="mono dim">{c.rank}</td>
<td className="mono">
<b>{c.symbol}</b>
</td>
<td className="mono">{c.score.toFixed(4)}</td>
<td style={{ fontSize: 12 }}>
{Object.entries(c.factor_values)
.map(([k, v]) => `${k}=${typeof v === "number" ? v.toFixed(4) : v}`)
.join(" ")}
</td>
<td>
<div className="chips">
{c.selection_reason.map((r) => (
<span className="chip" key={r}>{r.slice(0, 40)}</span>
))}
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
{result.unimplemented.length ? (
<div style={{ marginTop: 10 }}>
<Banner tone="info">{result.unimplemented.join(";")}</Banner>
</div>
) : null}
</Card>
);
}