feat(web): 字段库页 + 界面单位换算 + 因子目录参数化 + 选股策略条件表单

字段库与单位:
- `/fields`:字段库管理页(中文名/说明可改、可停用;kind 是引擎事实不可改);
  单位只在字段自己的阶梯里选(总市值 = 万元/亿元),越界 422 原样展示。
- `lib/units.ts`:界面单位 ⇄ 基准单位换算集中一处,条件输入按界面单位回显、
  提交前换回基准单位(引擎只认基准单位,库里存的也永远是基准单位)。
- `SelectionStrategyForm` 取代 `StrategyParamsForm`:一个策略只定义「怎么选」
  (股票池 + 因子 + 过滤条件),条件字段来自字段库接口而不是前端硬编码枚举。

因子参数化(名字即身份,界面不许藏):
- `/factors` 新增「参数」列与展开行:精确引擎键、每个参数的允许范围、来源、依赖列
  (依赖列标明「引擎事实,不可改」);说明文案从「不可修改」改为
  「改参数 = 新建参数化因子 = 新身份,旧因子/既有策略不变义」。
- 「新建参数化因子」卡片:模板下拉 + 受控窗口(min/max)+ 方向枚举下拉,
  实时预览规范键/中文名/渲染公式;后端 422 的原文原样展示,不静默截断。
- 因子下拉显示中文名(含参数)与短键,**value 一律是完整引擎键**;
  停用的因子从各页候选里消失(/strategies、/selection、/signals、/factors/compose),
  既有策略/归档仍按名字解析;`/factors/compose` 顺手修了勾选框点击目标过小。
- `/fields` 加提示:字段库只列内置因子名,参数化因子在 /factors 管理,
  选股策略条件下拉里会一并出现。

tsc --noEmit 0 error;verify_ui_alignment 8 页 160 项全过(另跑 3 个选因子页 51/54)。
This commit is contained in:
Simon
2026-10-01 16:38:29 +08:00
parent 2e90f3eeac
commit f13b34c59e
19 changed files with 2982 additions and 1521 deletions
+148 -235
View File
@@ -1,23 +1,17 @@
"use client";
/**
* 策略库(/strategies)—— 策略的命名资产中心。
* 选股策略库(/strategies)—— 只管理「选股条件组合」。
*
* 职责:
* - 列表 + 检索:每个策略显示**一句话说明**与**计算公式**(后端 describe_strategy 推导)。
* - 新建 / 编辑:复用 StrategyParamsForm(与回测页同一份参数模型与校验)。
* - 一键回测:`POST /strategies/{id}/expand`(补 period + 资金)→ `POST /jobs` → 轮询 →
* 就地展示核心指标,并提供「查看实验详情 / 回到回测页看曲线」。
* - 去回测页:`/backtest?strategy={id}`(回测页载入后可保存为策略、可微调)。
*
* 设计取舍:编辑不弹窗而是就地展开表单(页面滚动位置不丢,参数多,弹窗太挤)。
* 2026-09 重构后这里**不再有回测参数**(资金/持仓/调仓/费率/区间都移到回测组合与公共配置)。
* 每个策略只回答「怎么选」:股票池 + 因子 + 过滤条件。要验证它,去 /backtest 把它
* (可与其他策略一起)放进一个回测组合再跑。
*/
import Link from "next/link";
import { useCallback, useEffect, useMemo, useState } from "react";
import { apiDelete, apiGet, apiPost, apiPut } from "@/lib/api";
import { STAGE_LABEL, submitJob, waitJob, type JobOutcome } from "@/lib/jobs";
import { recentRange } from "@/lib/dates";
import type { BacktestResult, FactorMeta, StrategyDefinition } from "@/lib/types";
import type { ConditionField, FactorMeta, ResearchCondition, SelectionStrategy } from "@/lib/types";
import { displayUnitOf, fromBase, unitScale } from "@/lib/units";
import {
Btn,
Banner,
@@ -26,40 +20,67 @@ import {
Loading,
PageHeader,
Pill,
Progress,
BacktestMetrics,
Field,
} from "@/components/ui";
import {
StrategyParamsForm,
casePreset,
emptyParams,
paramsFromStrategy,
strategyFromParams,
validateParams,
type StrategyParams,
} from "@/components/StrategyParamsForm";
SelectionStrategyForm,
emptySelectionParams,
paramsFromSelectionStrategy,
selectionStrategyFromParams,
validateSelectionParams,
type SelectionStrategyParams,
} from "@/components/SelectionStrategyForm";
import { StrategyDocBody, StrategyDocCard } from "@/components/StrategyDocCard";
import { useStrategyDoc, useStrategyDocById } from "@/lib/strategy";
import { useStrategyDocById } from "@/lib/strategy";
export default function StrategiesPage() {
const [list, setList] = useState<StrategyDefinition[] | null>(null);
const [list, setList] = useState<SelectionStrategy[] | null>(null);
const [factors, setFactors] = useState<FactorMeta[]>([]);
const [conditionFields, setConditionFields] = useState<ConditionField[]>([]);
/**
* 条件下拉用的字段表 = 字段库 + 因子目录里「字段库还没收录」的因子。
*
* 为什么:参数化因子(`momentum(window=90,direction=…)`)是引擎真认的过滤字段
* (`momentum_60 > 0` 一直合法),但它们不在字段库的注册表投影里。这里按名字去重地
* 并进来 —— 内置因子本来就在字段库的「因子」分组里,不会重复出现。
* 依赖列之类仍由字段库提供;因子条目的单位恒为空(因子是没有单位的无量纲量)。
*/
const filterFields = useMemo<ConditionField[]>(() => {
const known = new Set(conditionFields.map((f) => f.name));
const factorFields: ConditionField[] = factors
.filter((f) => !known.has(f.name) && f.enabled !== false && f.resolvable !== false)
.map((f) => ({
name: f.name,
label: f.label || f.name,
description: `${f.description}${f.brief ? ` 用法:${f.brief}` : ""}`,
kind: "num",
group_name: "因子",
unit: "",
source: f.source === "custom" ? "custom" : "builtin",
enabled: true,
sort_order: 900,
ops: ["gt", "gte", "lt", "lte", "eq", "ne"],
}));
return [...conditionFields, ...factorFields];
}, [conditionFields, factors]);
/** 字段名 → 字段库条目:卡片回显条件时据此取中文名与界面单位。 */
const fieldByName = useMemo(
() => new Map(filterFields.map((f) => [f.name, f])),
[filterFields],
);
/** 因子名 → 目录条目:卡片/Pill 显示中文名(含参数),而不是又长又生的引擎键。 */
const factorByKey = useMemo(() => new Map(factors.map((f) => [f.name, f])), [factors]);
const [query, setQuery] = useState("");
const [err, setErr] = useState("");
const [msg, setMsg] = useState("");
const [editing, setEditing] = useState<StrategyParams | null>(null);
const [editing, setEditing] = useState<SelectionStrategyParams | null>(null);
const [editingId, setEditingId] = useState<string | null>(null);
// 保存被拦下过 → 立即展开全部校验错误(见 StrategyParamsFormProps.revealErrors)
const [revealErrors, setRevealErrors] = useState(false);
const [busy, setBusy] = useState(false);
const [docId, setDocId] = useState<string | null>(null);
const range = useMemo(() => recentRange(), []);
const load = useCallback(async () => {
try {
const rows = await apiGet<StrategyDefinition[]>("/strategies");
const rows = await apiGet<SelectionStrategy[]>("/strategies");
setList(rows);
setErr("");
} catch (e) {
@@ -74,6 +95,10 @@ export default function StrategiesPage() {
apiGet<FactorMeta[]>("/factors")
.then((f) => alive && setFactors(f))
.catch(() => alive && setFactors([]));
// 字段库只取启用项:停用的字段不该出现在条件下拉里(管理在 /fields)
apiGet<ConditionField[]>("/condition-fields?include_disabled=false")
.then((f) => alive && setConditionFields(f))
.catch(() => alive && setConditionFields([]));
return () => {
alive = false;
};
@@ -96,19 +121,18 @@ export default function StrategiesPage() {
});
}, [list, query]);
/* ---------- 新建 / 编辑 ---------- */
function startCreate() {
setEditingId(null);
setEditing(emptyParams(range));
setEditing(emptySelectionParams());
setDocId(null);
setMsg("");
setErr("");
setRevealErrors(false);
}
function startEdit(s: StrategyDefinition) {
function startEdit(s: SelectionStrategy) {
setEditingId(s.id ?? null);
setEditing(paramsFromStrategy(s, { ...emptyParams(range) }));
setEditing(paramsFromSelectionStrategy(s));
setDocId(s.id ?? null);
setMsg("");
setErr("");
@@ -117,22 +141,22 @@ export default function StrategiesPage() {
async function save() {
if (!editing) return;
const errors = validateParams(editing, { requireMeta: true });
const errors = validateSelectionParams(editing);
if (Object.keys(errors).length) {
setErr(Object.values(errors)[0]);
setRevealErrors(true); // 一次把问题全列出来,而不是让用户逐个试
setRevealErrors(true);
return;
}
setBusy(true);
setErr("");
try {
const body = strategyFromParams(editing, editingId ?? undefined);
const body = selectionStrategyFromParams(editing, editingId ?? undefined);
if (editingId) {
await apiPut<StrategyDefinition>(`/strategies/${encodeURIComponent(editingId)}`, body);
setMsg(`已更新策略「${body.name}」`);
await apiPut<SelectionStrategy>(`/strategies/${encodeURIComponent(editingId)}`, body);
setMsg(`已更新选股策略「${body.name}」`);
} else {
const saved = await apiPost<StrategyDefinition>("/strategies", body);
setMsg(`已保存策略「${saved.name}」(${saved.id})`);
const saved = await apiPost<SelectionStrategy>("/strategies", body);
setMsg(`已保存选股策略「${saved.name}」(${saved.id})`);
}
setEditing(null);
setEditingId(null);
@@ -145,13 +169,13 @@ export default function StrategiesPage() {
}
}
async function remove(s: StrategyDefinition) {
async function remove(s: SelectionStrategy) {
if (!s.id) return;
if (!window.confirm(`删除策略「${s.name}」?此操作不可撤销(已跑过的实验不受影响)。`)) return;
if (!window.confirm(`删除选股策略「${s.name}」?引用它的回测组合将无法运行。`)) return;
setBusy(true);
try {
await apiDelete(`/strategies/${encodeURIComponent(s.id)}`);
setMsg(`已删除策略「${s.name}」`);
setMsg(`已删除选股策略「${s.name}」`);
await load();
} catch (e) {
setErr((e as Error).message);
@@ -163,15 +187,15 @@ export default function StrategiesPage() {
return (
<>
<PageHeader
title="策略库"
sub="把调好的参数存成命名策略:每个策略都有「一句话说明 + 计算公式 + 执行步骤」,可一键回测、可复现、可对比。"
title="选股策略库"
sub="每个策略只定义「怎么选」(股票池 + 因子 + 过滤条件)。要验证收益,去「回测组合」把它和一个或多个策略组合起来、填上回测参数再跑。"
actions={
<div className="row" style={{ gap: 8 }}>
<Link href="/experiments" className="btn">
<span>查看实验</span>
<Link href="/backtest" className="btn">
<span>去建回测组合</span>
</Link>
<Btn variant="primary" icon="plus" onClick={startCreate} disabled={busy}>
新建策略
新建选股策略
</Btn>
</div>
}
@@ -180,11 +204,10 @@ export default function StrategiesPage() {
{err ? <Banner tone="error">{err}</Banner> : null}
{msg ? <Banner tone="info">{msg}</Banner> : null}
{/* ---------- 编辑器 ---------- */}
{editing ? (
<Card
icon={editingId ? "edit" : "plus"}
title={editingId ? `编辑策略 · ${editingId}` : "新建策略"}
title={editingId ? `编辑选股策略 · ${editingId}` : "新建选股策略"}
tools={
<div className="row" style={{ gap: 8 }}>
<Btn onClick={() => { setEditing(null); setEditingId(null); setDocId(null); }} disabled={busy}>
@@ -196,68 +219,48 @@ export default function StrategiesPage() {
</div>
}
>
<StrategyParamsForm
<SelectionStrategyForm
value={editing}
onChange={setEditing}
factorOptions={factors}
showMeta
showPeriod={false}
conditionFields={filterFields}
disabled={busy}
errors={validateParams(editing, { requireMeta: true })}
errors={validateSelectionParams(editing)}
revealErrors={revealErrors}
/>
<div className="hint" style={{ marginTop: 10 }}>
策略只保存「怎么选股/怎么调仓/怎么收费」,**不含回测区间与初始资金** ——
这两项在运行回测时才指定,因此同一策略可用于不同区间的复现与对比。
</div>
<div className="row" style={{ marginTop: 12, gap: 8 }}>
<Btn
icon="target"
disabled={busy}
onClick={() =>
setEditing({
...casePreset(range),
name: editing.name,
description: editing.description,
})
}
>
载入高股息案例默认参数
</Btn>
<div className="hint" style={{ marginTop: 12 }}>
这里<b>不填</b>资金 / 持仓数 / 持仓时间 / 调仓时机 / 费率 / 复权 / 回测区间 ——
那些是回测时才定的,在「回测组合」里填;费率与复权在「公共配置」里设。
</div>
</Card>
) : null}
{editing ? (
<EditingDoc params={editing} savedId={editingId} />
) : null}
{editing ? <EditingDoc id={editingId} /> : null}
{docId && !editing ? <SavedDoc id={docId} onClose={() => setDocId(null)} /> : null}
{/* ---------- 列表 ---------- */}
<Card
icon="archive"
title={`已保存策略${list ? ` · ${list.length}` : ""}`}
title={`选股策略${list ? ` · ${list.length}` : ""}`}
tools={
<input
className="input input--search"
placeholder="搜索策略名 / 说明 / 因子 / 条件"
value={query}
onChange={(e) => setQuery(e.target.value)}
aria-label="搜索策略"
aria-label="搜索选股策略"
/>
}
>
{filtered === null ? (
<Loading label="读取策略库…" />
<Loading label="读取选股策略库…" />
) : filtered.length === 0 ? (
<Empty
icon="archive"
title={list && list.length ? "没有匹配的策略" : "策略库还是空的"}
title={list && list.length ? "没有匹配的选股策略" : "选股策略库还是空的"}
hint={
list && list.length
? "换个关键词试试。"
: "点右上角「新建策略」保存第一个策略;也可以先去回测页调好参数,再点「保存为策略」。"
: "点右上角「新建选股策略」保存第一个;定义好「怎么选」后,去回测组合里验证收益。"
}
/>
) : (
@@ -266,8 +269,9 @@ export default function StrategiesPage() {
<StrategyCard
key={s.id ?? s.name}
s={s}
range={range}
busy={busy}
fieldByName={fieldByName}
factorByKey={factorByKey}
onEdit={() => startEdit(s)}
onDelete={() => remove(s)}
onDoc={() => setDocId(s.id ?? null)}
@@ -282,17 +286,22 @@ export default function StrategiesPage() {
/* ------------------------------------------------------------------ */
/** 未保存参数的实时说明预览 */
function EditingDoc({ params, savedId }: { params: StrategyParams; savedId: string | null }) {
const live = useStrategyDoc(params, !savedId);
const saved = useStrategyDocById(savedId);
const state = savedId ? saved : live;
function EditingDoc({ id }: { id: string | null }) {
// 编辑未保存时无法预览后端说明(需要 id);保存后才能看
const state = useStrategyDocById(id);
if (!id) {
return (
<Card icon="book" title="策略说明与计算公式">
<div className="hint">保存后即可在此预览后端按选股条件推导的说明与公式。</div>
</Card>
);
}
return (
<StrategyDocCard
doc={state.doc}
loading={state.loading}
error={state.error}
title="策略说明与计算公式(随参数实时更新)"
title="策略说明与计算公式(后端按选股条件推导)"
/>
);
}
@@ -318,120 +327,64 @@ function SavedDoc({ id, onClose }: { id: string; onClose: () => void }) {
);
}
/** 单个策略卡片:说明 + 参数摘要 + 一键回测 */
function StrategyCard({
s,
range,
busy,
fieldByName,
factorByKey,
onEdit,
onDelete,
onDoc,
}: {
s: StrategyDefinition;
range: { start: string; end: string };
s: SelectionStrategy;
busy: boolean;
fieldByName: Map<string, ConditionField>;
factorByKey: Map<string, FactorMeta>;
onEdit: () => void;
onDelete: () => void;
onDoc: () => void;
}) {
const [start, setStart] = useState("2020-01-01");
const [end, setEnd] = useState(range.end);
const [capital, setCapital] = useState(1_000_000);
const [running, setRunning] = useState(false);
const [jobId, setJobId] = useState("");
const [result, setResult] = useState<BacktestResult | null>(null);
const [stage, setStage] = useState("");
const [expId, setExpId] = useState("");
const [error, setError] = useState("");
const [open, setOpen] = useState(false);
const sel = s.selection ?? {};
const costs = s.costs ?? {};
async function run() {
if (!s.id) return;
if (start >= end) {
setError("开始日期必须早于结束日期");
return;
}
setRunning(true);
setError("");
setResult(null);
setExpId("");
try {
const spec = await apiPost<Record<string, unknown>>(
`/strategies/${encodeURIComponent(s.id)}/expand`,
{ period: [start, end], initial_capital: capital }
);
const { job_id } = await submitJob(spec as never);
setJobId(job_id);
setStage("queued");
const out: JobOutcome<BacktestResult> = await waitJob<BacktestResult>(job_id, 900_000, (info) =>
setStage(info.stage)
);
if (out.status === "success" && out.result) {
setResult(out.result);
setExpId(out.experimentId ?? "");
} else {
setError(`任务${out.status}${out.error ? `:${out.error}` : ""}`);
}
} catch (e) {
setError((e as Error).message);
} finally {
setRunning(false);
setJobId("");
setStage("");
}
}
const u = s.universe ?? {};
return (
<div className="card" style={{ display: "flex", flexDirection: "column", gap: 10 }}>
<div className="between">
<div style={{ minWidth: 0 }}>
<div className="card__title" style={{ marginBottom: 2 }}>
{s.name}
</div>
<div className="card__title" style={{ marginBottom: 2 }}>{s.name}</div>
<div className="row" style={{ gap: 6, flexWrap: "wrap" }}>
<span className="mono hint">{s.id}</span>
{s.created_at ? <span className="hint">创建 {String(s.created_at).slice(0, 10)}</span> : null}
</div>
</div>
<Pill tone="violet" icon="target">
{s.factors?.map((f) => f.name).join(" + ") || "—"}
{s.factors?.map((f) => factorLabel(factorByKey.get(f.name), f.name)).join(" + ") || "—"}
</Pill>
</div>
<div className="strategy-desc">{s.description || "(无说明:建议补一句话说明,便于日后识别)"}</div>
<div className="strategy-desc">{s.description || "(无说明:建议补一句话,便于日后识别)"}</div>
<div className="chips">
<span className="chip">
候选池 <b>{sel.top_n ?? "—"}</b> → 持仓 <b>{sel.hold_top_x ?? sel.top_n ?? "—"}</b>
</span>
<span className="chip">
择股 <b>{s.selection_interval_months ?? "跟随 y"}</b> 月 / 调仓{" "}
<b>{s.rebalance_interval_months ?? "跟随 m"}</b> 月
</span>
<span className="chip">
复权 <b>{adjLabel(s.price_adjustment)}</b>
</span>
<span className="chip">
{s.universe?.exclude_st ? "剔除 ST" : "含 ST"} · 费率{" "}
<b>{((costs.commission_rate ?? 0) * 100).toFixed(3)}%</b> + 印花{" "}
<b>{((costs.stamp_tax_rate ?? 0) * 100).toFixed(2)}%</b>
股票池 <b>{u.index_code ? `${u.index_code} 成分` : "全市场"}</b>
{u.exclude_st ? " · 剔 ST" : ""}
{u.min_listing_days ? ` · ≥${u.min_listing_days}天` : ""}
</span>
{(s.conditions ?? []).length ? (
<span className="chip">
条件 <b>{(s.conditions ?? []).map((c) => `${c.field} ${opLabel(c.op)} ${c.value ?? ""}`).join(" 且 ")}</b>
条件{" "}
<b>
{(s.conditions ?? [])
.map((c) => `${fieldByName.get(c.field)?.label ?? c.field} ${opLabel(c.op)} ${condValueText(c, fieldByName.get(c.field))}`)
.join(" 且 ")}
</b>
</span>
) : null}
) : (
<span className="chip">无过滤条件</span>
)}
</div>
<div className="strategy-actions">
<Btn size="sm" variant="primary" icon="play" loading={running} disabled={running || busy} onClick={run}>
{running ? "后台运行中…" : "一键回测"}
</Btn>
<Link href={`/backtest?strategy=${encodeURIComponent(s.id ?? "")}`} className="btn btn--sm">
<span>载入回测页(可微调)</span>
<span>加入回测组合</span>
</Link>
<Btn size="sm" icon="book" onClick={onDoc} disabled={busy}>
看说明/公式
@@ -443,77 +396,19 @@ function StrategyCard({
删除
</Btn>
</div>
{/* 运行区间(默认 2020-01-01 ~ 最近交易日) */}
<details open={open} onToggle={(e) => setOpen((e.target as HTMLDetailsElement).open)}>
<summary className="hint" style={{ cursor: "pointer" }}>
回测区间与资金(一键回测使用)
</summary>
<div className="form-grid" style={{ marginTop: 8 }}>
<Field label="开始日期">
<input type="date" className="input" value={start} onChange={(e) => setStart(e.target.value)} disabled={running} />
</Field>
<Field label="结束日期">
<input type="date" className="input" value={end} onChange={(e) => setEnd(e.target.value)} disabled={running} />
</Field>
<Field label="初始资金(元)">
<input
type="number"
className="input"
step={100000}
min={10000}
value={capital}
onChange={(e) => setCapital(Number(e.target.value))}
disabled={running}
/>
</Field>
</div>
</details>
{running ? (
<Progress
value={35}
label={
<>
任务 <span className="mono">{jobId || "排队中…"}</span> 后台执行中(全市场回测通常 1~5 分钟)
</>
}
/>
) : null}
{error ? <Banner tone="error">{error}</Banner> : null}
{result ? (
<div>
<div className="between" style={{ marginBottom: 8 }}>
<Pill tone="pos" icon="check">
回测完成
</Pill>
<Link
href={expId ? `/experiments?exp=${encodeURIComponent(expId)}` : "/experiments"}
className="btn btn--sm"
>
<span>在实验中对比</span>
</Link>
{expId ? (
<>
<Link href={`/experiments/${encodeURIComponent(expId)}`} className="btn btn--sm">
<span>打开归档(完整快照)</span>
</Link>
<Link href={`/backtest?from_experiment=${encodeURIComponent(expId)}`} className="btn btn--sm">
<span>以此参数再跑</span>
</Link>
</>
) : null}
</div>
<BacktestMetrics s={result.summary} />
</div>
) : null}
</div>
);
}
function adjLabel(m?: string): string {
return m === "hfq" ? "后复权" : m === "qfq" ? "前复权" : "不复权";
/**
* 卡片上的因子名:优先中文名(含参数,如「动量(窗口 90,越高越好)」)。
*
* 参数化因子的引擎键很长(`momentum(window=90,direction=higher_is_better)`),
* 卡片上直接显示会把布局撑坏;找不到目录条目时退回键名 —— 预览不到就照实显示,
* 不猜、不截断成看起来像另一个因子。
*/
function factorLabel(meta: FactorMeta | undefined, name: string): string {
return meta?.label || name;
}
function opLabel(op: string): string {
@@ -521,3 +416,21 @@ function opLabel(op: string): string {
{ gt: ">", gte: "≥", lt: "<", lte: "≤", eq: "=", ne: "≠", in: "属于", not_in: "不属于" }[op] ?? op
);
}
/** 条件取值的人类可读文本:存的是**基准单位**,这里按字段库的界面单位回显。
*
* 卡片上必须带单位(如「≥ 5 亿元」):引擎按基准单位比较,用户看到的若是裸数字,
* 就会把「5 亿元」读成「5 万元」—— 这正是「单位可选」要避免的误读。
*/
function condValueText(c: ResearchCondition, meta?: ConditionField): string {
if (c.ref) return `字段 ${c.ref}`;
const scale = unitScale(meta);
const unit = displayUnitOf(meta) || (meta?.base_unit ?? meta?.unit ?? "");
const show = (x: unknown) => {
const n = Number(x);
return Number.isNaN(n) ? String(x ?? "") : String(fromBase(n, scale));
};
const body = Array.isArray(c.value) ? c.value.map(show).join("、") : show(c.value);
return unit ? `${body} ${unit}` : body;
}