Files
qlib/frontend/web/components/StrategyParamsForm.tsx
T
Simon 50a1030afa fix(web): 参数表单收束 + 一句话说明改多行(按截图实测修两处失调)
用户给了真实截图,指出两处肉眼可见的不协调,逐项定位后修复:

1. 「一句话说明」是单行 input 被塞进 ~360px 的格子,placeholder
   「例:全市场股息率最高的 2」直接截断 —— 内容太多、框太小。
   → 改为整宽多行 <textarea>(rows=2、min-height 64px、可纵向拉伸),
     与定宽 340px 的策略名并排(.params-meta flex),窄屏自动上下堆叠;
     实时字数 (N/300) 已在提示里。

2. 「太多地方没有对齐」的根因:表单铺满 ~1900px 内容区,5 列各 ~360px,
   控件拉得太开;因子下拉用 1fr 撑到 ~1500px 而权重框才 118px,比例失调;
   数字输入还有 max-width:180px 上限,比同行下拉窄一截。
   → .params-form 约束到 ~1100px(每列 ~210px,所有控件按 1fr 等宽,
     左右边缘严格对齐);移除已多余的 max-width:180px(限宽后它反而
     制造新的不等宽),数值/日期改为填满单元格;因子行首列封顶 460px、
     整行限宽 720px,条件行同理(首列 ≤360px、限宽 760px);
     复权口径选项去掉括号里的「股息策略推荐」(已在 hint 说明),不再截断。

验证方式也升级了:这次是**截图后用 read_image 看渲染像素**确认,而不只量几何。
对齐自检同步把 textarea(高度随行数变、本就该比单行高)排除出「同排等高 /
高度归一」比较,并把数字过宽阈值调到 240px(限宽后 ~210px 属正常)。

门禁:tsc 0 错误、图表单测 7/7、next build 成功、页面 200、
对齐自检 140/140、契约自检 59/59;/backtest 与 /strategies 截图复核通过。
2026-09-30 19:56:40 +08:00

820 lines
32 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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";
/**
* 策略参数表单(受控组件)—— 策略库 / 回测页 / 选股直通 共用同一份参数模型。
*
* 为什么抽出来:策略库要「新建/编辑策略」、回测页要「保存为策略/从策略载入」、
* 选股页要「按此条件回测」,三处字段与校验完全同构。若各写一遍,必然出现
* 「选股页能设的条件在回测页设不了」这类口径漂移(本平台的核心风险)。
* 因此参数只有一个模型 `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>
);
}