Files
qlib/frontend/web/app/fields/page.tsx
T
Simon f13b34c59e 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)。
2026-10-01 16:38:29 +08:00

435 lines
18 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";
/**
* 字段库(/fields)—— 过滤条件可用字段的目录与维护页。
*
* 解决三个问题(用户 2026-10 反馈):
* 1. 过滤条件的字段此前要**手填**(dv_ratio / static.industry …),看不到含义、写错不报错;
* 现在字段有中文名 + 口径说明 + 单位 + 类型,条件编辑器里是分组下拉。
* 2. 字段库可增减、可编辑:改中文名/含义/单位/分组,停用不用的,新增少用但引擎支持的。
* 3. 说清「打分因子 vs 过滤条件」的关系 —— 见下方说明卡。
*
* 诚实性约束:能加什么由**引擎**决定(后端校验)。加一个引擎算不出来的字段,条件会
* 永远不通过 —— 所以这里只允许从「引擎支持但尚未进库」的清单里挑,不允许手填乱造。
*/
import { useCallback, useEffect, useState } from "react";
import Link from "next/link";
import { apiDelete, apiGet, apiPost, apiPut } from "@/lib/api";
import type { ConditionField, ConditionFieldOption } from "@/lib/types";
import { factorOf } from "@/lib/units";
import { Banner, Btn, Card, Empty, Field, Loading, PageHeader, Pill } from "@/components/ui";
/** 编辑中的行(null=未编辑)。 */
type Draft = {
label: string;
description: string;
unit: string;
group_name: string;
enabled: boolean;
};
const KIND_LABEL: Record<ConditionField["kind"], string> = { num: "数值", str: "文本" };
/** 单位选择器:**只能从注册表给的阶梯里选**(万元 ⇄ 亿元 这类固定换算),不给自由文本。
*
* 单位分两层:基准单位(引擎存储/比较用,改不了)与界面单位(这里选的,只影响输入/显示)。
* 选成亿元后,策略表单里按亿元输入、提交时 ×10000 存成万元 —— 引擎比较口径不变,
* 所以改单位不会让任何历史策略变义。只有一个单位的字段直接显示出来(没什么可选的)。
*/
function UnitSelect({
field,
value,
onChange,
}: {
field: ConditionField;
value: string;
onChange: (unit: string) => void;
}) {
const units = field.units ?? [];
const base = field.base_unit || field.unit;
if (units.length < 2) {
return (
<span className="hint" title={`引擎按基准单位「${base}」存储与比较`}>
{value || base}
</span>
);
}
return (
<div className="cell-stack" style={{ gap: 2 }}>
<select
className="input"
style={{ width: 88 }}
aria-label={`${field.label} 的界面单位`}
value={value}
onChange={(e) => onChange(e.target.value)}
>
{units.map((u) => (
<option key={u.unit} value={u.unit}>
{u.unit}
</option>
))}
</select>
<span className="hint cell-hint">
1 {value} = {factorOf(units, value)} {base}
</span>
</div>
);
}
export default function FieldsPage() {
const [rows, setRows] = useState<ConditionField[] | null>(null);
const [options, setOptions] = useState<ConditionFieldOption[]>([]);
const [err, setErr] = useState("");
const [msg, setMsg] = useState("");
const [busy, setBusy] = useState(false);
// 行内编辑状态
const [editName, setEditName] = useState<string | null>(null);
const [draft, setDraft] = useState<Draft | null>(null);
// 新增表单
const [addName, setAddName] = useState("");
const [addLabel, setAddLabel] = useState("");
const [addDesc, setAddDesc] = useState("");
const [addUnit, setAddUnit] = useState("");
const load = useCallback(async () => {
try {
const [list, avail] = await Promise.all([
apiGet<ConditionField[]>("/condition-fields"),
apiGet<ConditionFieldOption[]>("/condition-fields/available"),
]);
setRows(list);
setOptions(avail);
} catch (e) {
setErr((e as Error).message);
}
}, []);
useEffect(() => {
void load();
}, [load]);
function startEdit(f: ConditionField) {
setErr("");
setMsg("");
setEditName(f.name);
setDraft({
label: f.label,
description: f.description,
unit: f.unit,
group_name: f.group_name,
enabled: f.enabled,
});
}
async function saveEdit() {
if (!editName || !draft) return;
setBusy(true);
setErr("");
try {
await apiPut<ConditionField>(`/condition-fields/${encodeURIComponent(editName)}`, draft);
setMsg(`已保存字段「${draft.label}」`);
setEditName(null);
setDraft(null);
await load();
} catch (e) {
setErr((e as Error).message);
} finally {
setBusy(false);
}
}
/** 停用/启用:内置字段不能删,只能停用(删了下次读取会自动补回)。 */
async function toggleEnabled(f: ConditionField) {
setBusy(true);
setErr("");
try {
await apiPut<ConditionField>(`/condition-fields/${encodeURIComponent(f.name)}`, {
enabled: !f.enabled,
});
setMsg(`字段「${f.label}」已${f.enabled ? "停用(条件编辑器里不再出现)" : "启用"}`);
await load();
} catch (e) {
setErr((e as Error).message);
} finally {
setBusy(false);
}
}
async function remove(f: ConditionField) {
if (!window.confirm(`删除自定义字段「${f.label}」(${f.name})?删掉后可重新从「新增字段」加回来。`)) {
return;
}
setBusy(true);
setErr("");
try {
await apiDelete(`/condition-fields/${encodeURIComponent(f.name)}`);
setMsg(`已删除自定义字段「${f.label}」`);
await load();
} catch (e) {
setErr((e as Error).message);
} finally {
setBusy(false);
}
}
function pickOption(name: string) {
setAddName(name);
const o = options.find((x) => x.name === name);
if (o) {
setAddLabel(o.label);
setAddDesc(o.description);
setAddUnit(o.unit);
}
}
async function addField() {
if (!addName) return;
setBusy(true);
setErr("");
setMsg("");
try {
const created = await apiPost<ConditionField>("/condition-fields", {
name: addName,
label: addLabel,
description: addDesc,
unit: addUnit,
});
setMsg(`已新增字段「${created.label}」(${created.name}),现在可以在过滤条件里选它`);
setAddName("");
setAddLabel("");
setAddDesc("");
setAddUnit("");
await load();
} catch (e) {
setErr((e as Error).message);
} finally {
setBusy(false);
}
}
// 分组展示:后端已按 sort_order 排序,这里只做「保持首次出现顺序」的分组
const groups: { name: string; items: ConditionField[] }[] = [];
for (const f of rows ?? []) {
const g = groups.find((x) => x.name === f.group_name);
if (g) g.items.push(f);
else groups.push({ name: f.group_name, items: [f] });
}
const groupNames = groups.map((g) => g.name);
return (
<>
<PageHeader
title="字段库"
sub="过滤条件能用的字段目录:中文名、口径、单位都在这里定义。内置字段随引擎自动同步;中文名与含义可以改成你自己的说法,用不到的可以停用,少用的可以从「新增字段」加进来。"
actions={
<div className="row" style={{ gap: 8 }}>
<Link href="/strategies" className="btn"><span>去编辑选股策略</span></Link>
</div>
}
/>
{err ? <Banner tone="error">{err}</Banner> : null}
{msg ? <Banner tone="info">{msg}</Banner> : null}
{/* 因子 vs 条件的关系(用户问题第 3 条) */}
<Card icon="info" title="打分因子 与 过滤条件 是什么关系?">
<div className="stack" style={{ gap: 10 }}>
<div>
<b>一句话:条件决定「有没有资格」,因子决定「谁排前面」。</b>
两者在选股流程里是<b>先后两步</b>,不是二选一。
</div>
<div className="chips">
<span className="chip"><b>1 股票池</b> 剔除 ST / 上市天数不足 / 非指数成分</span>
<span className="chip"><b>2 过滤条件</b> 全部 AND 通过才有资格(不排序)</span>
<span className="chip"><b>3 打分因子</b> 横截面 z-score 加权 → 复合分排序</span>
<span className="chip"><b>4 取 TopN</b> N 在回测组合里定,不在策略里</span>
</div>
<div className="hint">
<b>用你库里的策略举例(高股息 Top20):</b>
条件 <code className="mono">dv_ratio ≤ 30</code> 先剔掉「股息率 &gt; 30% 的异常样本」
(多为一次性特别分红或股价暴跌,是典型的「高股息陷阱」)—— 这是<b>准入</b>;
因子 <code className="mono">dividend_yield</code> 给剩下的股票打分排序,
让股息率更高的排前面 —— 这是<b>优先级</b>。
同一个字段(比如股息率)既可以是条件也可以是因子:当条件用就是「筛掉」,当因子用就是「排序」。
</div>
<div className="hint">
另外两点容易混:<b>股票池</b>(剔 ST、上市天数、指数成分)也是过滤,但它属于策略的
「universe」,在条件之前执行;<b>取多少只(N)</b>不属于选股策略 ——
同一个策略配不同 N 是不同风险收益,所以它在「回测组合」里填。
</div>
<div className="hint">
<b>这里只列内置因子名</b>(如 <code className="mono">momentum_60</code>)。
自己新建的<b>参数化因子</b>(如 <code className="mono">momentum(window=90,direction=higher_is_better)</code>)
在<a href="/factors">因子研究</a>页管理,选股策略的条件字段下拉里会一并出现 ——
它们算的是同一个引擎字段,只是参数不同。
</div>
</div>
</Card>
{/* 新增字段 */}
<Card icon="plus" title="新增字段" sub="只能从「引擎支持但尚未进库」的字段里挑:加一个算不出来的字段,条件会永远不通过">
{options.length === 0 ? (
<div className="hint">没有可新增的字段了 —— 引擎支持且未进库的字段都已加入。</div>
) : (
<div className="form-grid">
<Field label="字段" className="field--wide">
<select
className="input"
aria-label="可新增字段"
value={addName}
onChange={(e) => pickOption(e.target.value)}
>
<option value="">选择字段…</option>
{options.map((o) => (
<option key={o.name} value={o.name}>
{o.group_name} · {o.label}({o.name})
</option>
))}
</select>
</Field>
<Field label="中文名" hint="空则用默认">
<input className="input" value={addLabel} onChange={(e) => setAddLabel(e.target.value)} />
</Field>
<Field label="界面单位" hint="只能从引擎给定的单位里选">
{(() => {
const opt = options.find((o) => o.name === addName);
const units = opt?.units ?? [];
const base = opt?.unit ?? "";
if (units.length < 2) {
return <span className="hint">{base || "—"}(该字段只有基准单位)</span>;
}
return (
<select
className="input"
aria-label="新增字段的界面单位"
value={addUnit}
onChange={(e) => setAddUnit(e.target.value)}
>
{units.map((u) => (
<option key={u.unit} value={u.unit}>
{u.unit}(1 {u.unit} = {u.factor} {base})
</option>
))}
</select>
);
})()}
</Field>
<Field label="含义 / 口径" className="field--wide" hint="写清口径,将来才看得懂">
<input
className="input"
value={addDesc}
onChange={(e) => setAddDesc(e.target.value)}
placeholder="例:总市值 = 总股本 × 收盘价(万元)"
/>
</Field>
<Btn icon="plus" variant="primary" disabled={busy || !addName} onClick={addField}>
加入字段库
</Btn>
</div>
)}
</Card>
<Card icon="filter" title={`字段目录 · ${rows?.length ?? 0} 个`} tools={
<Btn icon="refresh" size="sm" onClick={() => void load()} disabled={busy}>刷新</Btn>
}>
{rows === null ? (
<Loading label="加载字段库…" />
) : rows.length === 0 ? (
<Empty icon="filter" title="字段库为空" hint="刷新后会从引擎注册表自动补齐内置字段。" />
) : (
<div className="stack" style={{ gap: 14 }}>
{groups.map((g) => (
<div key={g.name}>
<div className="form-section__title">{g.name} · {g.items.length}</div>
<div className="table-wrap">
<table className="tbl">
<thead>
<tr>
<th style={{ width: 150 }}>中文名</th>
<th style={{ width: 190 }}>字段名</th>
<th style={{ width: 96 }}>类型 / 单位</th>
<th>含义 / 口径</th>
<th style={{ width: 96 }}>来源</th>
<th style={{ width: 170 }}>操作</th>
</tr>
</thead>
<tbody>
{g.items.map((f) =>
editName === f.name && draft ? (
<tr key={f.name} className="row-active">
<td><input className="input" value={draft.label} onChange={(e) => setDraft({ ...draft, label: e.target.value })} /></td>
<td className="cell-mono">{f.name}</td>
<td>
<UnitSelect
field={f}
value={draft.unit}
onChange={(unit) => setDraft({ ...draft, unit })}
/>
</td>
<td>
<input className="input" value={draft.description} onChange={(e) => setDraft({ ...draft, description: e.target.value })} />
</td>
<td><Pill>{f.source === "builtin" ? "内置" : "自定义"}</Pill></td>
<td>
<div className="row" style={{ gap: 6 }}>
<Btn size="sm" variant="primary" disabled={busy} onClick={saveEdit}>保存</Btn>
<Btn size="sm" disabled={busy} onClick={() => { setEditName(null); setDraft(null); }}>取消</Btn>
</div>
</td>
</tr>
) : (
<tr key={f.name} style={f.enabled ? undefined : { opacity: 0.55 }}>
<td className="cell-strong">
{f.label}
{!f.enabled ? <> <Pill tone="warn">已停用</Pill></> : null}
</td>
<td className="cell-mono">{f.name}</td>
<td>
{KIND_LABEL[f.kind]}
{f.unit ? (
<span className="hint" style={{ marginLeft: 4 }}>
{f.unit}
{f.base_unit && f.unit !== f.base_unit ? (
<span title={`引擎按基准单位 ${f.base_unit} 存储与比较;界面按 ${factorOf(f.units, f.unit)} 倍换算`}>
(基准 {f.base_unit},×{factorOf(f.units, f.unit)})
</span>
) : null}
</span>
) : null}
</td>
<td>{f.description}</td>
<td><Pill>{f.source === "builtin" ? "内置" : "自定义"}</Pill></td>
<td>
<div className="row" style={{ gap: 6 }}>
<Btn size="sm" icon="edit" disabled={busy} onClick={() => startEdit(f)}>编辑</Btn>
<Btn size="sm" disabled={busy} onClick={() => toggleEnabled(f)}>
{f.enabled ? "停用" : "启用"}
</Btn>
{f.source === "custom" ? (
<Btn size="sm" variant="danger" icon="trash" disabled={busy} onClick={() => remove(f)}>
删除
</Btn>
) : null}
</div>
</td>
</tr>
)
)}
</tbody>
</table>
</div>
</div>
))}
<div className="hint">
分组顺序与可用比较符由引擎类型决定:{groupNames.length} 个分组。文本字段只能「等于 / 不等于 / 属于 /
不属于」,数值字段才能比大小 —— 这样不会摆出「行业 &gt; 5」这种永远为假的选项。
内置字段不能删除(删掉下次读取会自动补回),不想看到就「停用」。
</div>
</div>
)}
</Card>
</>
);
}