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)。
This commit is contained in:
Simon
2026-10-01 16:38:29 +08:00
parent 2e90f3eeac
commit f13b34c59e
19 changed files with 2982 additions and 1521 deletions
@@ -0,0 +1,628 @@
"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>
);
}
@@ -1,820 +0,0 @@
"use client";
/**
* 策略参数表单(受控组件)—— 策略库 / 回测页 / 选股直通 共用同一份参数模型。
*
* 为什么抽出来:策略库要「新建/编辑策略」、回测页要「保存为策略/从策略载入」、
* 选股页要「按此条件回测」,三处字段与校验完全同构。若各写一遍,必然出现
* 「选股页能设的条件在回测页设不了」这类口径漂移(本平台的核心风险)。
* 因此参数只有一个模型 `StrategyParams`,一个表单组件,一套校验。
*
* 组件**不持有业务状态**:value/onChange 由父组件控制,父组件负责提交与落库。
*/
import { useId, useState } from "react";
import type { FactorMeta, ResearchCondition, ResearchSpec, StrategyDefinition } from "@/lib/types";
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: "不属于" },
];
export interface StrategyParams {
name: string;
description: string;
/** 加权因子列表(打分公式 = Σ weight × factor) */
factors: { name: string; weight: number }[];
priceAdjustment: "none" | "qfq" | "hfq";
/** n:候选池(择股条件选出的股数) */
topN: number;
/** x:实际持仓数,必须 ≤ n */
holdX: number;
/** m:择股间隔(月),0 = 不单独设(跟随 y) */
mMonths: number;
/** y:调仓间隔(月),0 = 跟随 m */
yMonths: number;
rebalance: "monthly" | "weekly";
/**
* 买不进(涨停/停牌)时的补位策略 —— 三态,与后端两个互斥字段一一对应:
* - `substitute`:allow_substitute=true, defer_buy=false(换一只买)
* - `defer`:allow_substitute=false, defer_buy=true(顺延到之后首个不涨停的交易日)
* - `none`:两者皆 false(不补位,可能少持几只)
* 后端拒绝两者同时为 true,因此这里不提供「都选」的组合。
*/
fillPolicy: FillPolicy;
conditions: ResearchCondition[];
excludeSt: boolean;
minListingDays: number;
commission: number; // %
stamp: number; // %
slippage: number; // %
minCommission: number; // 元/笔
capital: number;
start: string;
end: string;
}
/** 用户案例默认参数:全市场股息率最高的 n 只 → 持仓前 x 只,每 m 个月择股、每 y 个月调仓。 */
export type FillPolicy = "substitute" | "defer" | "none";
export const CASE_PRESET: Omit<StrategyParams, "start" | "end" | "name" | "description"> = {
factors: [{ name: "dividend_yield", weight: 1 }],
priceAdjustment: "hfq",
topN: 20,
holdX: 20,
mMonths: 6,
yMonths: 6,
rebalance: "monthly",
fillPolicy: "defer",
conditions: [{ field: "dv_ratio", op: "lte", value: 30 }],
excludeSt: true,
minListingDays: 250,
commission: 0.03,
stamp: 0.05,
slippage: 0.1,
minCommission: 5,
capital: 1_000_000,
};
export function emptyParams(range: { start: string; end: string }): StrategyParams {
return {
...CASE_PRESET,
name: "",
description: "",
factors: [{ name: "momentum_60", weight: 1 }],
conditions: [],
start: range.start,
end: range.end,
};
}
export function casePreset(range: { start: string; end: string }): StrategyParams {
return { ...CASE_PRESET, name: "", description: "", start: "2020-01-01", end: range.end };
}
/** 参数 → ResearchSpec(回测提交体)。m/y 的 0 语义与后端一致。 */
export function paramsToSpec(p: StrategyParams): ResearchSpec {
const factors = p.factors.filter((f) => f.name.trim() !== "");
return {
type: "backtest",
universe: { exclude_st: p.excludeSt, min_listing_days: p.minListingDays },
price_adjustment: p.priceAdjustment,
factors: factors.length ? factors : [{ name: "momentum_60", weight: 1 }],
conditions: p.conditions.filter((c) => c.field.trim() !== ""),
selection: {
top_n: p.topN,
hold_top_x: p.holdX,
allow_substitute: p.fillPolicy === "substitute",
defer_buy: p.fillPolicy === "defer",
},
rebalance: p.rebalance,
// m=0 表示「每次调仓都择股」:若同时给了 y>0,则择股间隔跟随 y
// (后端禁止只给 y 而不给 m —— 无锚点无法确定择股日集合)
selection_interval_months: p.mMonths > 0 ? p.mMonths : p.yMonths > 0 ? p.yMonths : null,
rebalance_interval_months: p.yMonths > 0 ? p.yMonths : p.mMonths > 0 ? p.mMonths : null,
costs: {
commission_rate: p.commission / 100,
stamp_tax_rate: p.stamp / 100,
slippage_rate: p.slippage / 100,
min_commission: p.minCommission,
},
initial_capital: p.capital,
period: [p.start, p.end],
};
}
/** ResearchSpec → 参数(从实验详情「以此参数回测」时使用) */
export function paramsFromSpec(
spec: Partial<ResearchSpec> & { config_snapshot?: Record<string, unknown> },
base: StrategyParams
): StrategyParams {
const snap = (spec.config_snapshot ?? {}) as Partial<ResearchSpec>;
const s = (snap.factors ? snap : spec) as Partial<ResearchSpec>;
const sel: NonNullable<ResearchSpec["selection"]> = s.selection ?? { top_n: base.topN };
const costs: NonNullable<ResearchSpec["costs"]> = s.costs ?? {};
return {
...base,
factors: s.factors?.length ? s.factors.map((f) => ({ ...f })) : base.factors,
priceAdjustment: s.price_adjustment ?? base.priceAdjustment,
topN: sel.top_n ?? base.topN,
holdX: sel.hold_top_x ?? sel.top_n ?? base.holdX,
mMonths: s.selection_interval_months ?? base.mMonths,
yMonths: s.rebalance_interval_months ?? base.yMonths,
rebalance: s.rebalance ?? base.rebalance,
fillPolicy: sel.defer_buy ? "defer" : sel.allow_substitute === false ? "none" : "substitute",
conditions: (s.conditions ?? []).map((c) => ({ ...c })),
excludeSt: s.universe?.exclude_st ?? base.excludeSt,
minListingDays: s.universe?.min_listing_days ?? base.minListingDays,
commission: (costs.commission_rate ?? base.commission / 100) * 100,
stamp: (costs.stamp_tax_rate ?? base.stamp / 100) * 100,
slippage: (costs.slippage_rate ?? base.slippage / 100) * 100,
minCommission: costs.min_commission ?? base.minCommission,
capital: s.initial_capital ?? base.capital,
start: s.period?.[0] ?? base.start,
end: s.period?.[1] ?? base.end,
};
}
/** 已保存策略 → 参数 */
export function paramsFromStrategy(st: StrategyDefinition, base: StrategyParams): StrategyParams {
const sel = st.selection ?? {};
const costs = st.costs ?? {};
return {
...base,
name: st.name,
description: st.description ?? "",
factors: st.factors?.length ? st.factors.map((f) => ({ ...f })) : base.factors,
priceAdjustment: st.price_adjustment ?? base.priceAdjustment,
topN: sel.top_n ?? base.topN,
holdX: sel.hold_top_x ?? sel.top_n ?? base.holdX,
mMonths: st.selection_interval_months ?? base.mMonths,
yMonths: st.rebalance_interval_months ?? base.yMonths,
rebalance: st.rebalance ?? base.rebalance,
fillPolicy: sel.defer_buy ? "defer" : sel.allow_substitute === false ? "none" : "substitute",
conditions: (st.conditions ?? []).map((c) => ({ ...c })),
excludeSt: st.universe?.exclude_st ?? base.excludeSt,
minListingDays: st.universe?.min_listing_days ?? base.minListingDays,
commission: (costs.commission_rate ?? base.commission / 100) * 100,
stamp: (costs.stamp_tax_rate ?? base.stamp / 100) * 100,
slippage: (costs.slippage_rate ?? base.slippage / 100) * 100,
minCommission: costs.min_commission ?? base.minCommission,
};
}
/** 参数 → 策略定义(保存到策略库;period/capital 不入库,回测时再补) */
export function strategyFromParams(p: StrategyParams, id?: string): StrategyDefinition {
const factors = p.factors.filter((f) => f.name.trim() !== "");
return {
...(id ? { id } : {}),
name: p.name.trim(),
description: p.description.trim(),
spec_type: "backtest",
universe: { exclude_st: p.excludeSt, min_listing_days: p.minListingDays },
price_adjustment: p.priceAdjustment,
factors: factors.length ? factors : [{ name: "momentum_60", weight: 1 }],
conditions: p.conditions.filter((c) => c.field.trim() !== ""),
selection: {
top_n: p.topN,
hold_top_x: p.holdX,
allow_substitute: p.fillPolicy === "substitute",
defer_buy: p.fillPolicy === "defer",
},
rebalance: p.rebalance,
selection_interval_months: p.mMonths > 0 ? p.mMonths : p.yMonths > 0 ? p.yMonths : null,
rebalance_interval_months: p.yMonths > 0 ? p.yMonths : p.mMonths > 0 ? p.mMonths : null,
costs: {
commission_rate: p.commission / 100,
stamp_tax_rate: p.stamp / 100,
slippage_rate: p.slippage / 100,
min_commission: p.minCommission,
},
portfolio: {},
};
}
/** 表单校验(返回 field → 错误文案;空对象 = 通过) */
export function validateParams(
p: StrategyParams,
opts: { requireMeta?: boolean } = {}
): Record<string, string> {
const e: Record<string, string> = {};
if (opts.requireMeta) {
if (!p.name.trim()) e.name = "策略名必填(便于在策略库中识别)";
else if (p.name.trim().length > 64) e.name = "策略名最多 64 字";
if (!p.description.trim()) e.description = "一句话说明必填:说清这个策略做什么";
// strategy.description 落库列为 String(300):超长会被 MySQL 严格模式拒绝,
// 因此在表单层就拦住并说明原因(而不是让用户在保存时吃一个 500)
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 {
// 后端 ResearchSpec 校验会拒绝重复因子名(z-score 叠加两次没有意义且易误读),
// 这里提前拦住,避免用户填完参数后才吃一个 400
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}」重复了:同一因子只应出现一次(想加权重请调权重值)`;
}
if (p.topN < 1) e.topN = "候选池 n 至少为 1";
if (p.holdX < 1) e.holdX = "持仓数 x 至少为 1";
if (p.holdX > p.topN) e.holdX = `持仓数 x=${p.holdX} 不能大于候选池 n=${p.topN}`;
if (p.mMonths < 0 || p.mMonths > 60) e.mMonths = "m 需在 0~60 之间";
if (p.yMonths < 0 || p.yMonths > 60) e.yMonths = "y 需在 0~60 之间";
if (p.commission < 0 || p.stamp < 0 || p.slippage < 0) e.costs = "费率不能为负";
if (p.minCommission < 0) e.minCommission = "最低佣金不能为负";
if (p.capital < 10000) e.capital = "初始资金建议 ≥ 1 万";
if (!p.start || !p.end) e.period = "起止日期都必填";
else if (p.start >= p.end) e.period = "开始日期必须早于结束日期";
for (const c of p.conditions) {
if (!c.field.trim()) {
e.conditions = "存在空的条件字段:请填写字段名或删除该条件";
break;
}
}
return e;
}
export interface StrategyParamsFormProps {
value: StrategyParams;
onChange: (next: StrategyParams) => void;
factorOptions: FactorMeta[];
/** 显示策略名 + 一句话说明(策略库编辑/保存为策略时) */
showMeta?: boolean;
/** 显示回测区间与初始资金(回测执行时才需要) */
showPeriod?: boolean;
/** 显示操作按钮区(表单内提交按钮) */
disabled?: boolean;
errors?: Record<string, string>;
/** 因子选择是否允许加权多项(默认允许) */
multiFactor?: boolean;
/**
* 是否立即显示全部校验错误。
*
* 默认 false:只在**字段失焦过**之后才显示该字段的错误。理由:受控输入在用户
* 清空内容准备重填的瞬间就会被判为「至少为 1」,立刻标红属于打扰式提示;
* 提交被拦下时父组件把本值设为 true,确保此时所有问题一次看清。
*/
revealErrors?: boolean;
}
export function StrategyParamsForm({
value: p,
onChange,
factorOptions,
showMeta = false,
showPeriod = true,
disabled = false,
errors = {},
multiFactor = true,
revealErrors = false,
}: StrategyParamsFormProps) {
// 稳定唯一前缀:同一个页面可能挂两份表单(策略库编辑 + 回测页),
// 写死 id 会撞车,label/for 与 aria 关联就会指错控件。
const fid = useId();
// 失焦过的字段才提示错误(见 revealErrors 说明)
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<StrategyParams>) => 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)) });
return (
<div className="params-form">
{showMeta && (
/* 策略名(定宽)+ 一句话说明(占满剩余宽度、多行)并排:
说明最长 300 字,塞进单行 input 必然截断(截图实测「例:全市场股息率最高的 2」
就被切掉),所以这里用 textarea 并给足高度。 */
<div className="params-meta">
<Field label="策略名(必填)" hint="在策略库中唯一" className="params-meta__name">
<input
className="input"
value={p.name}
maxLength={64}
placeholder="例:高股息 6 月择股 · 低频"
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="例:全市场股息率最高的 20 只,每 6 个月重新择股并等权持有"
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>
)}
{/* ---------- 因子(打分公式) ----------
用「表头 + 栅格行」表达多项因子:列宽由 CSS 栅格统一控制(不再在 JSX 里写
内联宽度),这样因子下拉、权重输入、删除按钮在所有行严格成列对齐;
视觉表头给正常用户,aria-label 给读屏(重复行只有表头时读屏无法分辨)。 */}
<div className="between" style={{ marginBottom: 8 }}>
<b className="form-section__title">打分因子(score = Σ 权重 × 因子值,越大越优先)</b>
{multiFactor && (
<Btn
icon="layers"
size="sm"
disabled={disabled}
onClick={() => set({ factors: [...p.factors, { name: 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) => (
<div className="factor-row" key={i}>
<select
id={`${fid}-name-${i}`}
className="input"
aria-label={`第 ${i + 1} 个因子的名称`}
value={f.name}
disabled={disabled}
onChange={(e) => setFactor(i, { name: e.target.value })}
>
{!factorOptions.some((o) => o.name === f.name) && f.name ? (
<option value={f.name}>{f.name}(未在因子表)</option>
) : null}
{factorOptions.map((o) => (
<option key={o.name} value={o.name}>
{o.name}
</option>
))}
</select>
<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) })}
/>
{multiFactor && p.factors.length > 1 ? (
/* 与同行的下拉/输入同为 md 高度:行内控件必须等高,否则整行看起来是斜的 */
<Btn
icon="x"
disabled={disabled}
aria-label={`删除第 ${i + 1} 个因子`}
title="删除该因子"
onClick={() => set({ factors: p.factors.filter((_, j) => j !== i) })}
/>
) : (
<span />
)}
</div>
))}
</div>
<div className="hint" style={{ marginTop: 6 }}>
权重 1 = 等权;只想用单个因子时把其它因子删掉即可。多个因子会先各自做横截面
z-score 标准化再加权,避免量纲不同互相压制。
</div>
{errors.factors && (
<div className="field-err" role="alert">
{errors.factors}
</div>
)}
{/* ---------- 选股规模与周期 ---------- */}
<div className="form-grid" style={{ marginTop: 12 }}>
<Field
label="候选池 n(择股条件选出股数)"
hint="范围 1~500;n 越大越分散,越小越集中"
>
<input
className={`input${errors.topN ? " input--invalid" : ""}`}
type="number"
inputMode="numeric"
min={1}
max={500}
value={p.topN}
disabled={disabled}
onChange={(e) => set({ topN: Number(e.target.value) })}
onBlur={blur("topN")}
/>
{showErr("topN") && (
<div className="field-err" role="alert">
{showErr("topN")}
</div>
)}
</Field>
<Field label="持仓数 x(≤ n)" hint="最终实际持有只数,通常等于 n">
<input
className={`input${errors.holdX ? " input--invalid" : ""}`}
type="number"
inputMode="numeric"
min={1}
max={p.topN}
value={p.holdX}
disabled={disabled}
onChange={(e) => set({ holdX: Number(e.target.value) })}
onBlur={blur("holdX")}
/>
{showErr("holdX") && (
<div className="field-err" role="alert">
{showErr("holdX")}
</div>
)}
</Field>
<Field label="择股间隔 m(月)" hint="多久重新挑一次股;0 = 跟随 y">
<input
className={`input${errors.mMonths ? " input--invalid" : ""}`}
type="number"
inputMode="numeric"
min={0}
max={60}
value={p.mMonths}
disabled={disabled}
onChange={(e) => set({ mMonths: Number(e.target.value) })}
onBlur={blur("mMonths")}
/>
{showErr("mMonths") && (
<div className="field-err" role="alert">
{showErr("mMonths")}
</div>
)}
</Field>
<Field label="调仓间隔 y(月)" hint="多久按最新选股结果换一次仓;0 = 跟随 m">
<input
className={`input${errors.yMonths ? " input--invalid" : ""}`}
type="number"
inputMode="numeric"
min={0}
max={60}
value={p.yMonths}
disabled={disabled}
onChange={(e) => set({ yMonths: Number(e.target.value) })}
onBlur={blur("yMonths")}
/>
{showErr("yMonths") && (
<div className="field-err" role="alert">
{showErr("yMonths")}
</div>
)}
</Field>
<Field label="调仓频率" hint="仅当 m、y 都为 0 时生效(否则由 m/y 决定)">
<select
className="input"
value={p.rebalance}
disabled={disabled}
onChange={(e) => set({ rebalance: e.target.value as "monthly" | "weekly" })}
>
<option value="monthly">月度</option>
<option value="weekly">周度</option>
</select>
</Field>
<Field
label="买不进时的补位(涨停 / 停牌)"
hint="后端两个字段互斥,这里用三选一表达,避免出现「都不生效」的静默组合"
className="field--wide"
>
<div className="radio-col">
{(
[
["substitute", "换一只买", "从候选池之外按复合分往下找可买标的,补足持仓数"],
["defer", "顺延买入", "等它到之后首个不涨停的交易日再按收盘价买入(到下次调仓仍未成交则作废)"],
["none", "不补位", "买不进就空着,实际持仓可能少于持仓数 x"],
] as const
).map(([val, label, desc]) => (
<label key={val} className="radio-row">
<input
type="radio"
/* 同一页面可能挂两份表单:name 必须带表单实例前缀,
否则两边的单选会互相取消选中(原生 radio 按 name 分组) */
name={`${fid}-fill-policy`}
checked={p.fillPolicy === val}
disabled={disabled}
onChange={() => set({ fillPolicy: val })}
/>
<span>
<b>{label}</b>
<span className="hint"> {desc}</span>
</span>
</label>
))}
</div>
</Field>
</div>
{/* ---------- 口径与成本 ---------- */}
<div className="form-grid" style={{ marginTop: 12 }}>
<Field label="复权口径" hint="股息类策略建议后复权 hfq(把分红再投资计入)">
<select
className="input"
value={p.priceAdjustment}
disabled={disabled}
onChange={(e) => set({ priceAdjustment: e.target.value as "none" | "qfq" | "hfq" })}
>
<option value="hfq">后复权 hfq</option>
<option value="qfq">前复权 qfq</option>
<option value="none">不复权 none</option>
</select>
</Field>
<Field label="标的范围" hint="ST 按择股日当时的股票名称判定">
<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="买卖双向收取;A 股常见 0.025%~0.03%"
>
<input
className={`input${errors.costs ? " input--invalid" : ""}`}
type="number"
inputMode="decimal"
step="0.01"
min={0}
max={1}
value={p.commission}
disabled={disabled}
onChange={(e) => set({ commission: Number(e.target.value) })}
/>
</Field>
<Field label="印花税率 %" hint="仅卖出收取;A 股 0.05%(2023 年 8 月起减半)">
<input
className={`input${errors.costs ? " input--invalid" : ""}`}
type="number"
inputMode="decimal"
step="0.01"
min={0}
max={1}
value={p.stamp}
disabled={disabled}
onChange={(e) => set({ stamp: Number(e.target.value) })}
/>
</Field>
<Field label="滑点率 %" hint="按成交价的比例估计冲击成本;0.05%~0.1% 较常见">
<input
className={`input${errors.costs ? " input--invalid" : ""}`}
type="number"
inputMode="decimal"
step="0.01"
min={0}
max={1}
value={p.slippage}
disabled={disabled}
onChange={(e) => set({ slippage: Number(e.target.value) })}
/>
</Field>
<Field label="最低佣金(元/笔)" hint="按笔收取的下限,如 5 元">
<input
className={`input${errors.minCommission ? " input--invalid" : ""}`}
type="number"
inputMode="decimal"
step="1"
min={0}
value={p.minCommission}
disabled={disabled}
onChange={(e) => set({ minCommission: Number(e.target.value) })}
onBlur={blur("minCommission")}
/>
{showErr("minCommission") && (
<div className="field-err" role="alert">
{showErr("minCommission")}
</div>
)}
</Field>
{/* 费率类错误的行内落点:三个费率共用一个错误键(费率不能为负) */}
{errors.costs && (
<div className="field-err" role="alert" style={{ gridColumn: "1 / -1" }}>
{errors.costs}
</div>
)}
{showPeriod && (
<>
<Field label="初始资金(元)" hint="回测初始本金,默认 100 万">
<input
className={`input${errors.capital ? " input--invalid" : ""}`}
type="number"
inputMode="numeric"
step={100000}
min={10000}
value={p.capital}
disabled={disabled}
onChange={(e) => set({ capital: Number(e.target.value) })}
onBlur={blur("capital")}
/>
{showErr("capital") && (
<div className="field-err" role="alert">
{showErr("capital")}
</div>
)}
</Field>
<Field
label="开始日期"
hint="回测起始日(含)"
className={showErr("period") ? "field--invalid" : undefined}
>
<input
type="date"
className={`input${showErr("period") ? " input--invalid" : ""}`}
value={p.start}
disabled={disabled}
onChange={(e) => set({ start: e.target.value })}
/>
</Field>
<Field
label="结束日期"
hint="回测结束日(含)"
className={showErr("period") ? "field--invalid" : undefined}
>
<input
type="date"
className={`input${showErr("period") ? " input--invalid" : ""}`}
value={p.end}
disabled={disabled}
onChange={(e) => set({ end: e.target.value })}
/>
</Field>
</>
)}
</div>
{showErr("period") && (
<div className="field-err" role="alert">
{showErr("period")}
</div>
)}
{/* ---------- 选股过滤条件 ---------- */}
<div style={{ marginTop: 14 }}>
<div className="between" style={{ marginBottom: 8 }}>
<b className="form-section__title">
选股过滤条件(AND,universe 之后、因子排序之前执行)
</b>
<Btn
icon="layers"
size="sm"
disabled={disabled}
onClick={() => set({ conditions: [...p.conditions, { field: "", op: "gte", value: 0 }] })}
>
添加条件
</Btn>
</div>
{p.conditions.length === 0 ? (
<div className="hint">未设置条件:候选池 = universe 内因子分最高的 n 只。</div>
) : (
<div className="cond-rows">
<div className="cond-rows__head" aria-hidden="true">
<span>字段</span>
<span>比较</span>
<span>取值</span>
<span />
</div>
{p.conditions.map((c, i) => (
<div className="cond-row" key={i}>
<input
className="input mono"
list={`${fid}-cond-fields`}
aria-label={`第 ${i + 1} 个条件的字段名`}
placeholder="字段(dv_ratio / pe / ma60 …)"
value={c.field}
disabled={disabled}
onChange={(e) =>
set({
conditions: p.conditions.map((x, j) =>
j === i ? { ...x, field: e.target.value } : x
),
})
}
/>
<select
className="input"
aria-label={`第 ${i + 1} 个条件的比较符`}
value={c.op}
disabled={disabled}
onChange={(e) =>
set({
conditions: p.conditions.map((x, j) =>
j === i ? { ...x, op: e.target.value as Op } : x
),
})
}
>
{OPS.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</select>
<input
className="input mono"
aria-label={`第 ${i + 1} 个条件的取值`}
inputMode="decimal"
placeholder="数值或字段名"
value={String(c.value ?? "")}
disabled={disabled}
onChange={(e) => {
const raw = e.target.value;
const num = Number(raw);
set({
conditions: p.conditions.map((x, j) =>
j === i
? { ...x, value: raw !== "" && !Number.isNaN(num) ? num : raw }
: x
),
});
}}
/>
<Btn
icon="x"
disabled={disabled}
aria-label={`删除第 ${i + 1} 个条件`}
title="删除该条件"
onClick={() => set({ conditions: p.conditions.filter((_, j) => j !== i) })}
/>
</div>
))}
</div>
)}
{errors.conditions && (
<div className="field-err" role="alert">
{errors.conditions}
</div>
)}
<div className="hint" style={{ marginTop: 6 }}>
可用字段:每日指标 dv_ratio / dv_ttm / pe / pb / total_mv、行情 close / volume /
amount、技术 ma20 / ma60、已注册因子名、static.industry 等、fundamental.roe 等
(财务按公告日 ≤ 择股日取用)。
</div>
</div>
</div>
);
}
+4 -2
View File
@@ -29,9 +29,11 @@ const GROUPS: { title: string; items: NavItem[] }[] = [
{
title: "策略",
items: [
{ href: "/strategies", label: "策略库", icon: "book" },
{ href: "/backtest", label: "选股回测", icon: "gauge" },
{ href: "/strategies", label: "选股策略库", icon: "book" },
{ href: "/fields", label: "字段库", icon: "filter" },
{ href: "/backtest", label: "回测组合", icon: "gauge" },
{ href: "/experiments", label: "实验对比", icon: "archive" },
{ href: "/settings", label: "公共配置", icon: "database" },
],
},
{