字段库与单位: - `/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)。
435 lines
18 KiB
TypeScript
435 lines
18 KiB
TypeScript
"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> 先剔掉「股息率 > 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} 个分组。文本字段只能「等于 / 不等于 / 属于 /
|
||
不属于」,数值字段才能比大小 —— 这样不会摆出「行业 > 5」这种永远为假的选项。
|
||
内置字段不能删除(删掉下次读取会自动补回),不想看到就「停用」。
|
||
</div>
|
||
</div>
|
||
)}
|
||
</Card>
|
||
</>
|
||
);
|
||
} |