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 通过
This commit is contained in:
@@ -135,7 +135,7 @@ export default function DashboardPage() {
|
||||
因子/回测请求经 <b>异步 Job</b> 后台执行并自动归档为实验(Experiment),可在「实验」页查看与复跑。
|
||||
</li>
|
||||
<li>
|
||||
数据底座为<b>本地 SQLite + Parquet</b>:行情经 <code>sync daily --resume</code> 增量同步;
|
||||
数据底座为<b>MySQL + Parquet</b>:行情经 <code>sync daily --resume</code> 增量同步;
|
||||
新浪数据仅在「两边一致」校验通过后兜底入库(<code>source=sina</code>)。
|
||||
</li>
|
||||
<li>
|
||||
|
||||
@@ -0,0 +1,387 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@@ -18,6 +18,7 @@ const GROUPS: { title: string; items: NavItem[] }[] = [
|
||||
items: [
|
||||
{ href: "/", label: "总览", icon: "grid" },
|
||||
{ href: "/stocks", label: "股票池", icon: "candles" },
|
||||
{ href: "/selection", label: "股票筛选", icon: "target" },
|
||||
{ href: "/factors", label: "因子研究", icon: "flask" },
|
||||
{ href: "/factors/compose", label: "因子组合", icon: "layers" },
|
||||
{ href: "/backtest", label: "选股回测", icon: "gauge" },
|
||||
@@ -109,7 +110,7 @@ export function AppShell({ children }: { children: React.ReactNode }) {
|
||||
<div className="rail-foot">
|
||||
数据源 Tushare 首选 · 新浪校验兜底
|
||||
<br />
|
||||
研究引擎 Qlib(本地 SQLite / Parquet)
|
||||
数据库 MySQL · 研究引擎 Qlib
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
|
||||
@@ -73,3 +73,56 @@ export interface FactorTestReport {
|
||||
sample_days: number;
|
||||
unimplemented: string[];
|
||||
}
|
||||
|
||||
/* ---- 选股系统(v2 §14/§21.1,与 domain/entities/selection.py 对应) ---- */
|
||||
|
||||
export interface SelectionCondition {
|
||||
field: string;
|
||||
op: "gt" | "gte" | "lt" | "lte" | "eq" | "ne" | "in" | "not_in";
|
||||
value?: number | string | (number | string)[] | null;
|
||||
ref?: string | null;
|
||||
}
|
||||
|
||||
export interface SelectionQuery {
|
||||
universe: { exclude_st?: boolean; min_listing_days?: number };
|
||||
as_of?: string | null; // null/省略 = 最近交易日
|
||||
method: "score" | "condition";
|
||||
factors: { name: string; weight: number }[];
|
||||
conditions: SelectionCondition[];
|
||||
top_n?: number | null;
|
||||
top_pct?: number | null;
|
||||
min_score?: number | null;
|
||||
}
|
||||
|
||||
export interface SelectionCandidate {
|
||||
symbol: string;
|
||||
rank: number;
|
||||
score: number;
|
||||
factor_values: Record<string, number>;
|
||||
filter_status: string[];
|
||||
selection_reason: string[];
|
||||
}
|
||||
|
||||
export interface SelectionResult {
|
||||
as_of_date: string;
|
||||
method: string;
|
||||
statistics: { universe_size: number; evaluated: number; selected: number };
|
||||
candidates: SelectionCandidate[];
|
||||
unimplemented: string[];
|
||||
config_snapshot: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface SelectionMeta {
|
||||
id: string;
|
||||
as_of: string;
|
||||
method: string;
|
||||
universe_size: number;
|
||||
selected: number;
|
||||
created_at?: string | null;
|
||||
}
|
||||
|
||||
export interface SelectionRun {
|
||||
selection_id: string;
|
||||
result: SelectionResult;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user