Files
qlib/frontend/web/app/selection/page.tsx
T
Simon 23972e7063 feat: 股息率案例口径 + 策略库与图表统一 + 回测存档完整化
汇总三轮未提交的开发(每轮均在本机 MariaDB + 真实浏览器上验证):

1) 股息率案例(全市场股息率最高 n 只,默认 20,每 m 月择股)
   - 新增日频估值表 daily_basic + 迁移;股息率因子(dv_ratio / dividend_yield / TTM)
   - 名称历史表 stock_name_history:剔除 ST 按**择股日当时名称**判定,消除
     「曾高股息后 ST」的股息陷阱(实测 3.70pp 偏差)
   - 区间择股/调仓双周期(m 择股 / y 调仓)、指数成分与白名单、停牌近似剔除
   - 复权因子口径核对(4,164,742 行、缺失 0.0%)、收盘价成交与涨跌停拦单
   - 案例实测:2020-01-01~2026-09-04 总收益 +24.86%(年化 3.52%、回撤 -28.58%)

2) 策略库与前端统一
   - strategy 表 + CRUD/PUT 原地更新 + `describe_strategy` 按 spec 真实推导
     「一句话说明 + 计算公式 + 执行步骤 + 注意事项」(与引擎实执行规则同源)
   - 任何出现股票代码处都成对显示名称且可点击进个股页
   - 全站图表基座统一 TradingView Lightweight Charts(ECharts 依赖、
     锁文件、组件与文档标注一并清除),买卖点标记只落在真实交易日上

3) 回测存档完整化(可往复查看)
   - 同步端点(POST /api/backtests、/api/factor-tests)此前完全不落库 → 现在同样归档,
     归档 id 经响应头 X-Experiment-Id 返回(不破坏 response_model)
   - data_version 首次真实写入(数据快照指纹:最新交易日 + 各表规模)
   - 个股收益曲线默认**全量保存**(此前硬截断 60 只);超出体积预算才裁剪,
     并写 archive_meta(机器可读)+ unimplemented(人可读)如实标注
   - 列表 kind/q 过滤 + X-Total-Count(此前 limit=50 静默截断)、DELETE 归档
   - 只读归档页 /experiments/{id}(Server Component,SSR 直出**选股条件**与
     **交易执行依据**);结果视图按 kind 分发(backtest/factor_test/selection),
     非回测归档不套用回测口径
   - 新增 CLI:prune_experiments(保留策略,默认 dry-run)、
     restore_experiment_from_job(从 Job 副本按原 id 重建被删的历史归档,默认 dry-run)

门禁:pytest 388 passed、ruff All checks passed、tsc 0 错误、图表单测 7 passed、
next build 成功、契约脚本 verify_strategy_workspace 59/59(含按 kind 逐类验证归档页)。
2026-09-20 07:31:04 +08:00

463 lines
17 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 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 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<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("");
/**
* 已落库的 selection_id —— 只有「同步选股(SelectionRun.selection_id)」和「从历史列表打开(id)」两条路径才有。
*
* 为什么不能直接用 selectionId:异步路径(POST /selections/jobs)拿到的是 JOB id,
* 后端不会为它写 selection 记录(backend/app/api/selections.py 只在同步接口里 selection_repo.save),
* 拿它拼 /backtest?from_selection=<JOB id> 会让接收端 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<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);
// 新一轮结果尚未落库/尚未返回:先清掉旧的 selection_id,避免「结果 A + 直通到记录 B」的错配链接
setSavedSelectionId("");
try {
const run = await apiPost<SelectionRun>("/selections", buildQuery());
setSelectionId(run.selection_id);
setSavedSelectionId(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);
// 同上:异步任务不落库选股记录,直通回测必须隐藏按钮(不给坏链接)
setSavedSelectionId("");
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);
setSavedSelectionId(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} selectionId={savedSelectionId} /> : 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, selectionId }: { result: SelectionResult; selectionId: string }) {
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>
{/* 选股 → 回测直通:只带「已落库」的 selection_id;没有它(如异步任务未落库)就不渲染按钮,避免坏链接 */}
{selectionId ? (
<Link
href={`/backtest?from_selection=${encodeURIComponent(selectionId)}`}
className="btn btn--sm"
title="把这次的规则(条件/因子/TopN/口径)预填到回测页;回测会在每个择股日按同一规则重新选股,不是固定持有这批股票"
>
<span>按此条件回测</span>
</Link>
) : null}
</>
}
>
{selectionId ? (
<div className="hint" style={{ marginBottom: 10 }}>
按此条件回测:把这次的规则(条件 / 因子 / TopN / 口径)预填到回测页;回测会在每个择股日按同一规则重新选股,<b>不是固定持有这批股票</b>。
</div>
) : null}
<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>
{/* 后端 payload 若已带 name 就用它(更权威),否则回落到全局名称表 */}
<SymbolLink symbol={c.symbol} name={payloadName(c)} />
</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>
);
}