"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 = { 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 ( {value || base} ); } return (
1 {value} = {factorOf(units, value)} {base}
); } export default function FieldsPage() { const [rows, setRows] = useState(null); const [options, setOptions] = useState([]); const [err, setErr] = useState(""); const [msg, setMsg] = useState(""); const [busy, setBusy] = useState(false); // 行内编辑状态 const [editName, setEditName] = useState(null); const [draft, setDraft] = useState(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("/condition-fields"), apiGet("/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(`/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(`/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("/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 ( <> 去编辑选股策略 } /> {err ? {err} : null} {msg ? {msg} : null} {/* 因子 vs 条件的关系(用户问题第 3 条) */}
一句话:条件决定「有没有资格」,因子决定「谁排前面」。 两者在选股流程里是先后两步,不是二选一。
1 股票池 剔除 ST / 上市天数不足 / 非指数成分 2 过滤条件 全部 AND 通过才有资格(不排序) 3 打分因子 横截面 z-score 加权 → 复合分排序 4 取 TopN N 在回测组合里定,不在策略里
用你库里的策略举例(高股息 Top20): 条件 dv_ratio ≤ 30 先剔掉「股息率 > 30% 的异常样本」 (多为一次性特别分红或股价暴跌,是典型的「高股息陷阱」)—— 这是准入; 因子 dividend_yield 给剩下的股票打分排序, 让股息率更高的排前面 —— 这是优先级。 同一个字段(比如股息率)既可以是条件也可以是因子:当条件用就是「筛掉」,当因子用就是「排序」。
另外两点容易混:股票池(剔 ST、上市天数、指数成分)也是过滤,但它属于策略的 「universe」,在条件之前执行;取多少只(N)不属于选股策略 —— 同一个策略配不同 N 是不同风险收益,所以它在「回测组合」里填。
这里只列内置因子名(如 momentum_60)。 自己新建的参数化因子(如 momentum(window=90,direction=higher_is_better)) 在因子研究页管理,选股策略的条件字段下拉里会一并出现 —— 它们算的是同一个引擎字段,只是参数不同。
{/* 新增字段 */} {options.length === 0 ? (
没有可新增的字段了 —— 引擎支持且未进库的字段都已加入。
) : (
setAddLabel(e.target.value)} /> {(() => { const opt = options.find((o) => o.name === addName); const units = opt?.units ?? []; const base = opt?.unit ?? ""; if (units.length < 2) { return {base || "—"}(该字段只有基准单位); } return ( ); })()} setAddDesc(e.target.value)} placeholder="例:总市值 = 总股本 × 收盘价(万元)" /> 加入字段库
)}
void load()} disabled={busy}>刷新 }> {rows === null ? ( ) : rows.length === 0 ? ( ) : (
{groups.map((g) => (
{g.name} · {g.items.length}
{g.items.map((f) => editName === f.name && draft ? ( ) : ( ) )}
中文名 字段名 类型 / 单位 含义 / 口径 来源 操作
setDraft({ ...draft, label: e.target.value })} /> {f.name} setDraft({ ...draft, unit })} /> setDraft({ ...draft, description: e.target.value })} /> {f.source === "builtin" ? "内置" : "自定义"}
保存 { setEditName(null); setDraft(null); }}>取消
{f.label} {!f.enabled ? <> 已停用 : null} {f.name} {KIND_LABEL[f.kind]} {f.unit ? ( {f.unit} {f.base_unit && f.unit !== f.base_unit ? ( (基准 {f.base_unit},×{factorOf(f.units, f.unit)}) ) : null} ) : null} {f.description} {f.source === "builtin" ? "内置" : "自定义"}
startEdit(f)}>编辑 toggleEnabled(f)}> {f.enabled ? "停用" : "启用"} {f.source === "custom" ? ( remove(f)}> 删除 ) : null}
))}
分组顺序与可用比较符由引擎类型决定:{groupNames.length} 个分组。文本字段只能「等于 / 不等于 / 属于 / 不属于」,数值字段才能比大小 —— 这样不会摆出「行业 > 5」这种永远为假的选项。 内置字段不能删除(删掉下次读取会自动补回),不想看到就「停用」。
)}
); }