Files
qlib/frontend/web/components/SelectionStrategyForm.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

629 lines
26 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";
/**
* 选股策略表单(2026-09 重构):只编辑「选股条件组合」。
*
* 与旧的 StrategyParamsForm 的区别:这里**没有**回测执行参数 ——
* 资金 / 持仓数 / 持仓时间 / 调仓时机 / 费率 / 复权 / 区间都不属于选股策略,
* 它们在「回测组合」(/backtest)里才填。策略库只回答一个问题:**怎么选**。
*
* 字段:名字 + 一句话说明 + 股票池(剔ST/上市天数/指数成分/白名单)+ 因子[名,权重] + 过滤条件。
*/
import Link from "next/link";
import { useId, useState } from "react";
import { factorOptionLabel } from "@/lib/factors";
import type { ConditionField, FactorMeta, ResearchCondition, SelectionStrategy } from "@/lib/types";
import { baseUnitOf, displayUnitOf, fromBase, toBase, unitScale } from "@/lib/units";
import { Btn, Field } from "@/components/ui";
export type Op = ResearchCondition["op"];
export const OPS: { value: Op; label: string }[] = [
{ value: "gt", label: ">" },
{ value: "gte", label: "≥" },
{ value: "lt", label: "<" },
{ value: "lte", label: "≤" },
{ value: "eq", label: "=" },
{ value: "ne", label: "≠" },
{ value: "in", label: "属于" },
{ value: "not_in", label: "不属于" },
];
const OP_LABEL: Record<string, string> = Object.fromEntries(OPS.map((o) => [o.value, o.label]));
/** 把「属于 / 不属于」的取值写成给人看的逗号分隔串(数组 value ↔ 文本)。 */
export function valueToText(
v: ResearchCondition["value"],
scale = 1,
asNumber = true,
): string {
if (v === null || v === undefined) return "";
// 存储值一律是**基准单位**;这里换算成界面单位显示(scale=1 时原样)
const show = (x: unknown) => {
if (!asNumber) return String(x);
const n = Number(x);
if (Number.isNaN(n)) return String(x);
return String(fromBase(n, scale));
};
if (Array.isArray(v)) return v.map(show).join(", ");
return show(v);
}
/** 文本 → 条件取值:属于/不属于 必为数组(引擎实体强制要求 list),其余按能否解析成数字。
* 数字按**界面单位**输入,写回时换算成基准单位(存储与引擎只用基准单位)。 */
export function textToValue(
raw: string,
op: Op,
scale = 1,
kind: "num" | "str" = "num",
): ResearchCondition["value"] {
const conv = (s: string): string | number => {
if (kind === "str") return s;
const n = Number(s);
return Number.isNaN(n) ? s : toBase(n, scale);
};
if (op === "in" || op === "not_in") {
return raw
.split(/[,,]/)
.map((s) => s.trim())
.filter(Boolean)
.map(conv);
}
if (raw === "") return "";
return conv(raw);
}
export interface SelectionStrategyParams {
name: string;
description: string;
factors: { name: string; weight: number }[];
conditions: ResearchCondition[];
excludeSt: boolean;
minListingDays: number;
indexCode: string;
}
export function emptySelectionParams(): SelectionStrategyParams {
return {
name: "",
description: "",
factors: [{ name: "dividend_yield", weight: 1 }],
conditions: [],
excludeSt: true,
minListingDays: 250,
indexCode: "",
};
}
/** 后端 SelectionStrategy → 表单参数 */
export function paramsFromSelectionStrategy(s: SelectionStrategy): SelectionStrategyParams {
const u = s.universe ?? {};
return {
name: s.name,
description: s.description,
factors: (s.factors ?? []).map((f) => ({ name: f.name, weight: f.weight })),
conditions: (s.conditions ?? []).map((c) => ({ ...c })),
excludeSt: u.exclude_st ?? true,
minListingDays: u.min_listing_days ?? 250,
indexCode: u.index_code ?? "",
};
}
/** 表单参数 → 后端 SelectionStrategy(id 可选,用于新建/更新) */
export function selectionStrategyFromParams(
p: SelectionStrategyParams,
id?: string
): SelectionStrategy {
return {
...(id ? { id } : {}),
name: p.name.trim(),
description: p.description.trim(),
universe: {
exclude_st: p.excludeSt,
min_listing_days: p.minListingDays,
index_code: p.indexCode.trim() || null,
},
factors: p.factors.filter((f) => f.name.trim()),
conditions: p.conditions.filter((c) => c.field.trim()),
};
}
export function validateSelectionParams(p: SelectionStrategyParams): Record<string, string> {
const e: Record<string, string> = {};
if (!p.name.trim()) e.name = "策略名必填(便于在策略库中识别)";
else if (p.name.trim().length > 64) e.name = "策略名最多 64 字";
if (!p.description.trim()) e.description = "一句话说明必填:说清这个策略怎么选";
else if (p.description.trim().length > 300)
e.description = `一句话说明最长 300 字(当前 ${p.description.trim().length} 字)`;
if (!p.factors.some((f) => f.name.trim())) e.factors = "至少选择一个因子";
else {
const used = p.factors.map((f) => f.name.trim()).filter(Boolean);
const dup = used.find((n, i) => used.indexOf(n) !== i);
if (dup) e.factors = `因子「${dup}」重复了:同一因子只应出现一次(想加权重请调权重值)`;
}
for (const c of p.conditions) {
if (!c.field.trim()) {
e.conditions = "存在空的条件字段:请选择字段或删除该条件";
break;
}
if (c.ref !== null && c.ref !== undefined && !String(c.ref).trim()) {
e.conditions = `条件「${c.field}」选择了「与另一字段比较」,但右侧字段是空的`;
break;
}
if (c.ref) continue; // 字段 vs 字段:右侧字段已校验
if (c.op === "in" || c.op === "not_in") {
if (!Array.isArray(c.value) || c.value.length === 0) {
e.conditions = `条件「${c.field} ${OP_LABEL[c.op]}」需要至少一个取值(多个值用逗号分隔)`;
break;
}
continue;
}
if (c.value === null || c.value === undefined || String(c.value).trim() === "") {
e.conditions = `条件「${c.field}」缺少取值`;
break;
}
}
return e;
}
export interface SelectionStrategyFormProps {
value: SelectionStrategyParams;
onChange: (next: SelectionStrategyParams) => void;
factorOptions: FactorMeta[];
/** 字段库(/api/condition-fields,只传启用项):条件的字段从这里选,附带中文名与含义。 */
conditionFields?: ConditionField[];
disabled?: boolean;
errors?: Record<string, string>;
revealErrors?: boolean;
}
/** 该字段允许的比较符(字段库里没登记过的字段 → 按数值处理,交给后端报错)。 */
function opsFor(meta: ConditionField | undefined): Op[] {
return (meta?.ops as Op[] | undefined) ?? ["gt", "gte", "lt", "lte", "eq", "ne"];
}
/** 换字段/换比较符时把比较符收敛到合法集合,避免出现「行业 > 5」这种永远为假的组合。 */
function pickOp(current: Op, meta: ConditionField | undefined): Op {
const allowed = opsFor(meta);
if (allowed.includes(current)) return current;
return meta?.kind === "str" ? "eq" : "gte";
}
/** 取值随比较符变形:属于/不属于 必须是数组(引擎实体强制),其余退回标量。 */
function coerceValue(v: ResearchCondition["value"], op: Op): ResearchCondition["value"] {
if (op === "in" || op === "not_in") {
if (Array.isArray(v)) return v;
return v === null || v === undefined || v === "" ? [] : [v as string | number];
}
return Array.isArray(v) ? (v[0] ?? "") : v;
}
/** 因子方向的人话(与「因子研究」页同一套说法)。 */
function directionText(d: FactorMeta["direction"]): string {
return d === "lower_is_better" ? "越低越好" : "越高越好";
}
/** 因子频率的人话(后端存 daily/weekly/monthly,界面不该露出英文枚举)。 */
function frequencyText(f: string): string {
return { daily: "日频", weekly: "周频", monthly: "月频" }[f] ?? f;
}
/** 选中因子后的说明:真实参数/方向/频率/回看/依赖列 + 简介(与字段库的口径提示同一位置)。 */
function factorHint(f: FactorMeta): string {
const parts: string[] = [];
// 真实参数(窗口等)放最前:这是「这个因子到底怎么算」的第一信息
const specs = f.param_specs ?? [];
const params = specs
.filter((s) => s.kind === "int")
.map((s) => `${s.label} ${f.params?.[s.name] ?? s.default}`)
.join("、");
if (params) parts.push(params);
parts.push(directionText(f.direction), frequencyText(f.frequency));
parts.push(f.lookback ? `回看 ${f.lookback} 日` : "时点值(无回看窗口)");
if (f.requires?.length) parts.push(`需要 ${f.requires.join(" / ")}`);
if (f.source === "custom") parts.push("目录里的参数化实例(参数写在名字里)");
return `${parts.join(" · ")} — ${f.brief || f.description}`;
}
export function SelectionStrategyForm({
value: p,
onChange,
factorOptions,
conditionFields = [],
disabled = false,
errors = {},
revealErrors = false,
}: SelectionStrategyFormProps) {
const fid = useId();
const [touched, setTouched] = useState<Record<string, boolean>>({});
const blur = (key: string) => () => setTouched((t) => ({ ...t, [key]: true }));
const showErr = (key: string) => (revealErrors || touched[key] ? errors[key] : undefined);
const set = (patch: Partial<SelectionStrategyParams>) => onChange({ ...p, ...patch });
const setFactor = (i: number, patch: Partial<{ name: string; weight: number }>) =>
set({ factors: p.factors.map((f, j) => (j === i ? { ...f, ...patch } : f)) });
// 字段库 → 分组下拉 + 按名查含义(后端已按 sort_order 排序,这里保持首次出现顺序)
const fieldByName = new Map(conditionFields.map((f) => [f.name, f]));
const fieldGroups: { name: string; items: ConditionField[] }[] = [];
for (const f of conditionFields) {
const g = fieldGroups.find((x) => x.name === f.group_name);
if (g) g.items.push(f);
else fieldGroups.push({ name: f.group_name, items: [f] });
}
return (
<div className="params-form">
{/* 名字 + 一句话说明 */}
<div className="params-meta">
<Field label="策略名(必填)" hint="在策略库中唯一" className="params-meta__name">
<input
className={`input${showErr("name") ? " input--invalid" : ""}`}
value={p.name}
maxLength={64}
placeholder="例:高股息防御"
disabled={disabled}
onChange={(e) => set({ name: e.target.value })}
onBlur={blur("name")}
/>
{showErr("name") && <div className="field-err" role="alert">{showErr("name")}</div>}
</Field>
<Field
label="一句话说明(必填)"
hint={`说清这个策略怎么选(${p.description.length}/300)`}
className="params-meta__desc"
>
<textarea
className={`input${showErr("description") ? " input--invalid" : ""}`}
rows={2}
value={p.description}
maxLength={300}
placeholder="例:全市场股息率最高、且 dv_ratio ≤ 30 的股票,剔除 ST 与次新股"
disabled={disabled}
onChange={(e) => set({ description: e.target.value })}
onBlur={blur("description")}
/>
{showErr("description") && (
<div className="field-err" role="alert">{showErr("description")}</div>
)}
</Field>
</div>
{/* 股票池 */}
<b className="form-section__title">股票池(universe)</b>
<div className="form-grid" style={{ marginTop: 8 }}>
<Field label="标的范围">
<label className="check">
<input
type="checkbox"
checked={p.excludeSt}
disabled={disabled}
onChange={(e) => set({ excludeSt: e.target.checked })}
/>
剔除 ST
</label>
</Field>
<Field label="最少上市天数" hint="避免次新股噪声;0 = 不限制">
<input
className="input"
type="number"
inputMode="numeric"
min={0}
step={10}
value={p.minListingDays}
disabled={disabled}
onChange={(e) => set({ minListingDays: Number(e.target.value) })}
/>
</Field>
<Field label="指数成分(可选)" hint="如 000300.SH = 仅沪深300成分股;留空 = 不限">
<input
className="input mono"
value={p.indexCode}
placeholder="000300.SH"
disabled={disabled}
onChange={(e) => set({ indexCode: e.target.value })}
/>
</Field>
</div>
{/* 因子 */}
<div className="between" style={{ marginTop: 14, marginBottom: 8 }}>
<b className="form-section__title">打分因子(score = Σ 权重 × 因子值,越大越优先)</b>
<Btn
icon="layers"
size="sm"
disabled={disabled}
onClick={() =>
set({
factors: [
...p.factors,
// 默认选中第一个「可用」的因子(停用的不该被默认塞进新策略)
{
name: (factorOptions.find((o) => o.enabled !== false) ?? factorOptions[0])?.name ?? "",
weight: 1,
},
],
})
}
>
添加因子
</Btn>
</div>
<div className="factor-rows">
<div className="factor-rows__head" aria-hidden="true">
<span>因子</span>
<span>权重</span>
<span />
</div>
{p.factors.map((f, i) => {
const meta = factorOptions.find((o) => o.name === f.name);
return (
<div className="factor-row" key={i}>
<div className="cell-stack">
<select
id={`${fid}-name-${i}`}
className="input"
aria-label={`第 ${i + 1} 个因子的名称`}
value={f.name}
disabled={disabled}
onChange={(e) => setFactor(i, { name: e.target.value })}
>
{!meta && f.name ? (
<option value={f.name}>{f.name}(未在因子表)</option>
) : null}
{/* 停用的因子不出现在候选里(管理在 /factors),但**当前已选中的那个必须留着**,
否则编辑历史策略时会显示成「未在因子表」,读起来像因子被删了。 */}
{factorOptions
.filter((o) => o.enabled !== false || o.name === f.name)
.map((o) => (
<option key={o.name} value={o.name}>
{factorOptionLabel(o)}
{o.enabled === false ? "(已停用)" : ""}
</option>
))}
</select>
<span className="hint cell-hint">
{meta
? factorHint(meta)
: "该名称不在因子目录里:可能是历史策略引用了已删除的因子,运行时会报错"}
</span>
</div>
<input
id={`${fid}-weight-${i}`}
className="input mono"
type="number"
inputMode="decimal"
step="0.1"
min={0}
aria-label={`第 ${i + 1} 个因子的权重`}
title="权重:越大表示该因子越重要(1 = 等权)"
value={f.weight}
disabled={disabled}
onChange={(e) => setFactor(i, { weight: Number(e.target.value) })}
/>
{p.factors.length > 1 ? (
<Btn
icon="x"
disabled={disabled}
aria-label={`删除第 ${i + 1} 个因子`}
title="删除该因子"
onClick={() => set({ factors: p.factors.filter((_, j) => j !== i) })}
/>
) : (
<span />
)}
</div>
);
})}
</div>
{showErr("factors") && <div className="field-err" role="alert">{showErr("factors")}</div>}
<div className="hint" style={{ marginTop: 6 }}>
权重 1 = 等权;多个因子会先各自做横截面 z-score 标准化再加权,避免量纲不同互相压制。
</div>
{/* 过滤条件 */}
<div className="between" style={{ marginTop: 14, marginBottom: 8 }}>
<b className="form-section__title">过滤条件(AND,股票池之后、因子排序之前执行)</b>
<div className="row" style={{ gap: 8 }}>
<Link href="/fields" className="btn btn--sm"><span>管理字段库</span></Link>
<Btn
icon="layers"
size="sm"
disabled={disabled}
onClick={() => set({ conditions: [...p.conditions, { field: "", op: "gte", value: 0 }] })}
>
添加条件
</Btn>
</div>
</div>
{p.conditions.length === 0 ? (
<div className="hint">
未设置条件:候选 = 股票池内因子分最高的若干只(具体取多少只在回测组合里定)。
条件用来「筛掉不要的」,因子用来「排序」—— 两者可以同时用。
</div>
) : (
<div className="cond-rows">
<div className="cond-rows__head" aria-hidden="true">
<span>字段</span>
<span>比较</span>
<span>取值方式</span>
<span>取值</span>
<span />
</div>
{p.conditions.map((c, i) => {
const meta = fieldByName.get(c.field);
const isRef = !!c.ref;
const kind = meta?.kind ?? "num";
// 单位换算:存储/引擎用基准单位,输入/显示用字段库选的界面单位
const scale = kind === "str" ? 1 : unitScale(meta);
const baseUnit = baseUnitOf(meta);
const displayUnit = displayUnitOf(meta);
const setCond = (patch: Partial<ResearchCondition>) =>
set({ conditions: p.conditions.map((x, j) => (j === i ? { ...x, ...patch } : x)) });
return (
<div className="cond-row" key={i}>
<div className="cell-stack">
<select
className="input"
aria-label={`第 ${i + 1} 个条件的字段`}
value={c.field}
disabled={disabled}
onChange={(e) => {
const nf = e.target.value;
const nm = fieldByName.get(nf);
const op = pickOp(c.op, nm);
// 从数值字段换到文本字段时清空取值:拿数字去比「行业」是永远为假的组合
const keepValue = !(nm?.kind === "str" && kind !== "str");
setCond({
field: nf,
op,
value: keepValue ? coerceValue(c.value, op) : coerceValue("", op),
});
}}
>
<option value="">选择字段…</option>
{fieldGroups.map((g) => (
<optgroup key={g.name} label={g.name}>
{g.items.map((f) => (
<option key={f.name} value={f.name}>
{f.label}
{f.unit ? `(${f.unit})` : ""} · {f.name}
</option>
))}
</optgroup>
))}
{c.field && !meta ? (
<option value={c.field}>⚠ 不在字段库:{c.field}</option>
) : null}
</select>
<span className="hint cell-hint">
{meta ? (
<>
{meta.description}
{scale !== 1 ? (
<>
{" "}
<b>
界面单位 {meta.unit}
</b>
:按 {meta.unit} 输入,提交时 ×{scale} 换算成基准单位 {baseUnit}
(引擎只按基准单位比较,所以改单位不会让已有策略变义)。
</>
) : null}
</>
) : c.field ? (
"该字段不在字段库中(可能已停用或删除):请重新选择,否则条件会一直不通过"
) : (
"选一个字段 —— 下方会显示它的口径与单位"
)}
</span>
</div>
<select
className="input"
aria-label={`第 ${i + 1} 个条件的比较符`}
value={c.op}
disabled={disabled}
onChange={(e) => {
const op = e.target.value as Op;
setCond({ op, value: coerceValue(c.value, op) });
}}
>
{OPS.filter((o) => opsFor(meta).includes(o.value)).map((o) => (
<option key={o.value} value={o.value}>{o.label}</option>
))}
</select>
<select
className="input"
aria-label={`第 ${i + 1} 个条件的取值方式`}
value={isRef ? "ref" : "value"}
disabled={disabled}
onChange={(e) => {
if (e.target.value === "ref") {
setCond({ ref: "", value: null });
} else {
setCond({ ref: null, value: "" });
}
}}
title="取值:和固定数值/文本比;字段:和另一个字段比(如 收盘价 > MA60)"
>
<option value="value">数值/文本</option>
<option value="ref">另一字段</option>
</select>
{isRef ? (
<select
className="input"
aria-label={`第 ${i + 1} 个条件比较的字段`}
value={c.ref ?? ""}
disabled={disabled}
onChange={(e) => setCond({ ref: e.target.value })}
>
<option value="">选择字段…</option>
{fieldGroups.map((g) => (
<optgroup key={g.name} label={g.name}>
{g.items.map((f) => (
<option key={f.name} value={f.name}>
{f.label} · {f.name}
</option>
))}
</optgroup>
))}
</select>
) : (
<div className="unit-input">
<input
className="input mono"
aria-label={`第 ${i + 1} 个条件的取值`}
inputMode={kind === "num" && c.op !== "in" && c.op !== "not_in" ? "decimal" : "text"}
placeholder={
c.op === "in" || c.op === "not_in"
? "多个值用逗号分隔"
: kind === "str"
? "文本,如 银行"
: "数值"
}
value={valueToText(c.value, scale)}
disabled={disabled}
onChange={(e) => setCond({ value: textToValue(e.target.value, c.op, scale, kind) })}
/>
{displayUnit ? (
<span className="unit-suffix" title={`界面单位 ${displayUnit};提交时按 ×${scale} 换算成基准单位 ${baseUnit}`}>
{displayUnit}
</span>
) : null}
</div>
)}
<Btn
icon="x"
disabled={disabled}
aria-label={`删除第 ${i + 1} 个条件`}
title="删除该条件"
onClick={() => set({ conditions: p.conditions.filter((_, j) => j !== i) })}
/>
</div>
);
})}
</div>
)}
{showErr("conditions") && <div className="field-err" role="alert">{showErr("conditions")}</div>}
{/* 因子 vs 条件:关系说明(用户 2026-10 反馈第 3 条) */}
<div className="relation-note">
<div className="relation-note__title">打分因子 与 过滤条件 的关系</div>
<div className="relation-note__flow">
<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>条件 = 准入</b>(筛掉不要的,不决定顺序);<b>因子 = 优先级</b>(决定谁排前面,
不筛掉任何人)。同一个字段两种用法都行:你的高股息策略里
<code className="mono">dv_ratio ≤ 30</code> 当条件用,是剔掉股息率异常偏高的
「高股息陷阱」样本;<code className="mono">dividend_yield</code> 当因子用,
是让股息率高的排前面。字段的含义与单位见
<Link href="/fields"> 字段库</Link>。
</div>
</div>
</div>
);
}