From 0ab90385704041a18d306b1dc179b389a286c2f8 Mon Sep 17 00:00:00 2001 From: Simon Date: Wed, 9 Sep 2026 00:25:27 +0800 Subject: [PATCH] =?UTF-8?q?feat(web):=20M6.5=20=E8=82=A1=E7=A5=A8=E7=AD=9B?= =?UTF-8?q?=E9=80=89=E9=A1=B5=EF=BC=88/selection=EF=BC=89=E2=80=94?= =?UTF-8?q?=E2=80=94=20=E8=AF=84=E5=88=86/=E6=9D=A1=E4=BB=B6=E5=8F=8C?= =?UTF-8?q?=E6=A8=A1=E5=BC=8F=20+=20=E5=BD=93=E5=89=8D/=E5=8E=86=E5=8F=B2?= =?UTF-8?q?=E9=80=89=E8=82=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 通过 --- frontend/web/app/page.tsx | 2 +- frontend/web/app/selection/page.tsx | 387 ++++++++++++++++++++++++++ frontend/web/components/app-shell.tsx | 3 +- frontend/web/lib/types.ts | 53 ++++ 4 files changed, 443 insertions(+), 2 deletions(-) create mode 100644 frontend/web/app/selection/page.tsx diff --git a/frontend/web/app/page.tsx b/frontend/web/app/page.tsx index a9d4e18..5b3dd5d 100644 --- a/frontend/web/app/page.tsx +++ b/frontend/web/app/page.tsx @@ -135,7 +135,7 @@ export default function DashboardPage() { 因子/回测请求经 异步 Job 后台执行并自动归档为实验(Experiment),可在「实验」页查看与复跑。
  • - 数据底座为本地 SQLite + Parquet:行情经 sync daily --resume 增量同步; + 数据底座为MySQL + Parquet:行情经 sync daily --resume 增量同步; 新浪数据仅在「两边一致」校验通过后兜底入库(source=sina)。
  • diff --git a/frontend/web/app/selection/page.tsx b/frontend/web/app/selection/page.tsx new file mode 100644 index 0000000..6eacb98 --- /dev/null +++ b/frontend/web/app/selection/page.tsx @@ -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([]); + 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([emptyCondition()]); + const [result, setResult] = useState(null); + const [selectionId, setSelectionId] = useState(""); + const [running, setRunning] = useState(false); + const [error, setError] = useState(""); + const [history, setHistory] = useState(null); + + useEffect(() => { + let alive = true; + apiGet("/factors") + .then((list) => alive && list.length > 0 && setFactors(list)) + .catch((e: Error) => alive && setError(e.message)); + apiGet("/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("/selections", buildQuery()); + setSelectionId(run.selection_id); + setResult(run.result); + apiGet("/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(`/selections/${id}`); + setSelectionId(id); + setResult(detail); + } catch (e) { + setError((e as Error).message); + } + } + + return ( + <> + + {selectionId} + + ) : null + } + /> + + +
    + + + + + setAsOf(e.target.value)} /> + + + + +
    + + {mode === "score" ? ( + <> +
    评分因子与权重
    + {factorRows.map((r, i) => ( +
    + + { + const next = [...factorRows]; + next[i] = { ...next[i], weight: Number(e.target.value) }; + setFactorRows(next); + }} + /> + +
    + ))} +
    + + + setTopN(Number(e.target.value))} /> + +
    + + ) : ( + <> +
    条件(全部满足才入选)
    + {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 ( +
    + + + {!isRef ? ( + { + 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} + +
    + ); + })} + + + )} + +
    + + {running ? "筛选中…" : "执行选股"} + +
    + {error ? ( +
    + {error} +
    + ) : null} +
    + + {!result && !running && history === null ? : null} + + {result ? : null} + + {history && history.length > 0 ? ( + +
    + {history.map((m) => ( + + ))} +
    +
    + ) : null} + + ); +} + +function ResultView({ result }: { result: SelectionResult }) { + const s = result.statistics; + if (result.candidates.length === 0) { + return ( + + + + ); + } + return ( + + 范围 {s.universe_size} + 评分 {s.evaluated} + 选出 {s.selected} + + } + > +
    + + + + + + + + + + + + {result.candidates.map((c) => ( + + + + + + + + ))} + +
    #代码得分因子值入选理由
    {c.rank} + {c.symbol} + {c.score.toFixed(4)} + {Object.entries(c.factor_values) + .map(([k, v]) => `${k}=${typeof v === "number" ? v.toFixed(4) : v}`) + .join(" ")} + +
    + {c.selection_reason.map((r) => ( + {r.slice(0, 40)} + ))} +
    +
    +
    + {result.unimplemented.length ? ( +
    + {result.unimplemented.join(";")} +
    + ) : null} +
    + ); +} diff --git a/frontend/web/components/app-shell.tsx b/frontend/web/components/app-shell.tsx index 2c8050b..383d3b4 100644 --- a/frontend/web/components/app-shell.tsx +++ b/frontend/web/components/app-shell.tsx @@ -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 }) {
    数据源 Tushare 首选 · 新浪校验兜底
    - 研究引擎 Qlib(本地 SQLite / Parquet) + 数据库 MySQL · 研究引擎 Qlib
    diff --git a/frontend/web/lib/types.ts b/frontend/web/lib/types.ts index 38c254a..cc1ea25 100644 --- a/frontend/web/lib/types.ts +++ b/frontend/web/lib/types.ts @@ -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; + 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; +} + +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; +} +