字段库与单位: - `/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)。
629 lines
26 KiB
TypeScript
629 lines
26 KiB
TypeScript
"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>
|
||
);
|
||
}
|