Files
qlib/frontend/web/app/selection/page.tsx
T
Simon 0ab9038570 feat(web): M6.5 股票筛选页(/selection)—— 评分/条件双模式 + 当前/历史选股
- app/selection/page.tsx:因子评分(多因子+权重+TopN)与条件模式(结构化条件行
  编辑:static.*/因子/close>ma60/ROE 等,AND 语义);as_of 留空=最近交易日;
  结果表(rank/symbol/score/因子值/入选理由)+ 最近选股记录点击回看
- lib/types.ts:SelectionQuery/Condition/Candidate/Result/Meta/Run DTO
- app-shell 导航加入「股票筛选」;rail 脚注与 Dashboard 文案 SQLite→MySQL(M-DB 收尾)
- 端到端验证:真实 MySQL 上 POST /api/selections 返回候选并落库可读回;
  /selection 页面 200;后端全量 pytest + 前端 tsc --noEmit 通过
2026-09-09 00:25:27 +08:00

388 lines
14 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 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 [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);
}
}
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} onClick={run}>
{running ? "筛选中…" : "执行选股"}
</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>
);
}