问题不是"不好看",而是**可测量的错位**:同一行里原生 date 输入 38.8px、数字输入
36.8px、按钮 34px;16px 的勾选框与 36.8px 的下拉同排;因子行的下拉与权重框没有
可见标签;列表里的勾选框点不中;参数非法时「运行回测」直接置灰且不说原因。
根因:控件高度靠「上下 padding + 行高」拼出来,而 input / select / button 的原生行高
各不相同(Chrome 的 date 还会多 2px),必然参差;加上各处内联像素宽度
(style={{width:220}}、flex:1)与自搓布局,列自然对不齐。
改动:
- 新增控件高度令牌 --ctl-h-sm/md/lg(28/34/38px)与 --ctl-px,.input/select/.btn/
.icon-btn/date 统一显式 height(不再拼 padding);原生 checkbox/radio 统一 16px,
点击热区交给外层 label(.check/.radio-row/.check--cell),表格整格可点
- 因子行、选股条件行改为「表头 + CSS 栅格」成列对齐,列宽由样式决定,
去掉内联像素宽度与 flex 拉伸,配 aria-label 供读屏分辨重复行
- 表单友好化:错误提示改为**失焦或提交后**才出现(清空重填的瞬间不再标红);
提交被拦下时一次展开全部行内错误 + 自动聚焦并滚动到第一个问题字段;
运行/保存按钮不再因参数非法而置灰(灰按钮不说原因 = 看起来不可点却无响应),
改为可点击并讲清原因;补齐 topN/costs 两处「产生了却没人显示」的行内错误落点
- 数值字段补 inputMode/step/min/max 与单位、取值范围提示;工具条检索/筛选用
.input--search/.input--filter/.input--picker 类,不再写内联宽度
- /experiments 筛选无结果的空态与「暂无实验」区分开(原文案会让人以为归档丢了)
- 同一页面可能挂两份表单:radio name 与 label/for 加表单实例前缀(useId),
否则两边单选互相取消、label 指错控件
- 新增 scripts/verify_ui_alignment.py:系统 Chrome + 原生 CDP(仅标准库,
独占随机端口与临时 profile),按 7 个页面 × 1500/375px 检查同排等高、
高度取值归一、点击目标、标签与无障碍名、字号圆角一致、尺寸匹配内容、
横向溢出、提示裁切;本次基线 108/32 → 现 140/140
验证:pytest 388 passed、ruff 全绿(顺带清掉 qlib_verify.py 一处死代码)、
tsc 0 错误、图表单测 7 passed、next build 成功、契约自检 59/59、
对齐自检 140/140(含 375px 小屏)。
813 lines
32 KiB
TypeScript
813 lines
32 KiB
TypeScript
"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 (
|
||
<>
|
||
{showMeta && (
|
||
<div className="form-grid">
|
||
<Field label="策略名(必填)" hint="在策略库中唯一">
|
||
<input
|
||
className="input"
|
||
value={p.name}
|
||
maxLength={64}
|
||
placeholder="例:高股息 6 月择股 · 低频"
|
||
disabled={disabled}
|
||
onChange={(e) => set({ name: e.target.value })}
|
||
/>
|
||
{errors.name && (
|
||
<div className="field-err" role="alert">
|
||
{errors.name}
|
||
</div>
|
||
)}
|
||
</Field>
|
||
<Field
|
||
label="一句话说明(必填)"
|
||
hint={`说清这个策略做什么、怎么选股(${p.description.length}/300,落库列宽上限)`}
|
||
>
|
||
<input
|
||
className="input"
|
||
value={p.description}
|
||
maxLength={300}
|
||
placeholder="例:全市场股息率最高的 20 只,每 6 个月重新择股并等权持有"
|
||
disabled={disabled}
|
||
onChange={(e) => set({ description: e.target.value })}
|
||
/>
|
||
{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>
|
||
</>
|
||
);
|
||
} |