"use client"; /** 股票筛选(选股系统 M6,v2 §14/§21.1): * 评分模式(多因子加权 TopN)与条件模式(结构化条件 AND); * 支持当前/历史 as_of;结果落库可查(selection_id),右侧展示最近选股历史。 */ import Link from "next/link"; import { useEffect, useState } from "react"; import { apiGet, apiPost } from "@/lib/api"; import { SymbolLink } from "@/lib/symbols"; import { waitJob } from "@/lib/jobs"; import { factorOptionLabel, pickableFactors } from "@/lib/factors"; 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"]; /** * 读取后端 payload 里的 name(若存在)。 * * 为什么用宽松读取而非直接 `c.name`:`lib/types.ts` 的 SelectionCandidate 目前没有 * 声明 name 字段(该文件由其他改动维护),直接访问会类型报错;但后端可能已经返回更权威的 * 中文名。这里只做「有且非空则用」的读取,取不到就交给 SymbolLink 渲染占位「—」, * 绝不编造名称(AGENT §24)。 */ function payloadName(row: unknown): string | undefined { if (row && typeof row === "object" && "name" in row) { const n = (row as { name?: unknown }).name; if (typeof n === "string" && n.trim()) return n; } return undefined; } 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(""); /** * 已落库的 selection_id —— 只有「同步选股(SelectionRun.selection_id)」和「从历史列表打开(id)」两条路径才有。 * * 为什么不能直接用 selectionId:异步路径(POST /selections/jobs)拿到的是 JOB id, * 后端不会为它写 selection 记录(backend/app/api/selections.py 只在同步接口里 selection_repo.save), * 拿它拼 /backtest?from_selection= 会让接收端 GET /api/selections/{id} 404 → 坏链接。 */ const [savedSelectionId, setSavedSelectionId] = useState(""); const [running, setRunning] = useState(false); const [asyncBusy, setAsyncBusy] = useState(false); const [error, setError] = useState(""); const [history, setHistory] = useState(null); useEffect(() => { let alive = true; apiGet("/factors") // 停用/算不出来的因子不摆进候选(停用仍可被既有策略解析,只是不该再被选中) .then((list) => alive && list.length > 0 && setFactors(pickableFactors(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); // 新一轮结果尚未落库/尚未返回:先清掉旧的 selection_id,避免「结果 A + 直通到记录 B」的错配链接 setSavedSelectionId(""); try { const run = await apiPost("/selections", buildQuery()); setSelectionId(run.selection_id); setSavedSelectionId(run.selection_id); setResult(run.result); apiGet("/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); // 同上:异步任务不落库选股记录,直通回测必须隐藏按钮(不给坏链接) setSavedSelectionId(""); try { const { job_id } = await apiPost<{ job_id: string }>("/selections/jobs", buildQuery()); const out = await waitJob(job_id); if (out.status === "success" && out.result) { setSelectionId(job_id); setResult(out.result); apiGet("/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(`/selections/${id}`); setSelectionId(id); setSavedSelectionId(id); setResult(detail); } catch (e) { setError((e as Error).message); } } return ( <> {selectionId} ) : null } />
setAsOf(e.target.value)} />
{mode === "score" ? ( <>
评分因子与权重
{/* 与回测页同一套「表头 + 栅格行」:列宽由 CSS 决定,不再用 flex 拉伸 (flex:1 会把权重框拉到几百像素宽,与一位小数的内容完全不匹配) */}
{factorRows.map((r, i) => (
{ const next = [...factorRows]; next[i] = { ...next[i], weight: Number(e.target.value) }; setFactorRows(next); }} /> setFactorRows(factorRows.filter((_, j) => j !== i))} />
))}
setFactorRows([...factorRows, { name: "momentum_60", weight: 1 }])} > 添加因子 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); }} /> ) : ( /* 占位保持第四列(操作列)不串位 */ )} setConds(conds.filter((_, j) => j !== i))} />
); })}
setConds([...conds, emptyCondition()])}> 添加条件
)}
{running ? "筛选中…" : "执行选股"} {asyncBusy ? "后台执行中…" : "异步(全市场)"}
{error ? (
{error}
) : null}
{!result && !running && history === null ? : null} {result ? : null} {history && history.length > 0 ? (
{history.map((m) => ( ))}
) : null} ); } function ResultView({ result, selectionId }: { result: SelectionResult; selectionId: string }) { const s = result.statistics; if (result.candidates.length === 0) { return ( ); } return ( 范围 {s.universe_size} 评分 {s.evaluated} 选出 {s.selected} {/* 选股 → 回测直通:只带「已落库」的 selection_id;没有它(如异步任务未落库)就不渲染按钮,避免坏链接 */} {selectionId ? ( 按此条件回测 ) : null} } > {selectionId ? (
按此条件回测:把这次的规则(条件 / 因子 / TopN / 口径)预填到回测页;回测会在每个择股日按同一规则重新选股,不是固定持有这批股票。
) : null}
{result.candidates.map((c) => ( ))}
# 代码 / 名称 得分 因子值 入选理由
{c.rank} {/* 后端 payload 若已带 name 就用它(更权威),否则回落到全局名称表 */} {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}
); }