Files
qlib/frontend/web/components/StrategyParamsForm.tsx
T
Simon 23972e7063 feat: 股息率案例口径 + 策略库与图表统一 + 回测存档完整化
汇总三轮未提交的开发(每轮均在本机 MariaDB + 真实浏览器上验证):

1) 股息率案例(全市场股息率最高 n 只,默认 20,每 m 月择股)
   - 新增日频估值表 daily_basic + 迁移;股息率因子(dv_ratio / dividend_yield / TTM)
   - 名称历史表 stock_name_history:剔除 ST 按**择股日当时名称**判定,消除
     「曾高股息后 ST」的股息陷阱(实测 3.70pp 偏差)
   - 区间择股/调仓双周期(m 择股 / y 调仓)、指数成分与白名单、停牌近似剔除
   - 复权因子口径核对(4,164,742 行、缺失 0.0%)、收盘价成交与涨跌停拦单
   - 案例实测:2020-01-01~2026-09-04 总收益 +24.86%(年化 3.52%、回撤 -28.58%)

2) 策略库与前端统一
   - strategy 表 + CRUD/PUT 原地更新 + `describe_strategy` 按 spec 真实推导
     「一句话说明 + 计算公式 + 执行步骤 + 注意事项」(与引擎实执行规则同源)
   - 任何出现股票代码处都成对显示名称且可点击进个股页
   - 全站图表基座统一 TradingView Lightweight Charts(ECharts 依赖、
     锁文件、组件与文档标注一并清除),买卖点标记只落在真实交易日上

3) 回测存档完整化(可往复查看)
   - 同步端点(POST /api/backtests、/api/factor-tests)此前完全不落库 → 现在同样归档,
     归档 id 经响应头 X-Experiment-Id 返回(不破坏 response_model)
   - data_version 首次真实写入(数据快照指纹:最新交易日 + 各表规模)
   - 个股收益曲线默认**全量保存**(此前硬截断 60 只);超出体积预算才裁剪,
     并写 archive_meta(机器可读)+ unimplemented(人可读)如实标注
   - 列表 kind/q 过滤 + X-Total-Count(此前 limit=50 静默截断)、DELETE 归档
   - 只读归档页 /experiments/{id}(Server Component,SSR 直出**选股条件**与
     **交易执行依据**);结果视图按 kind 分发(backtest/factor_test/selection),
     非回测归档不套用回测口径
   - 新增 CLI:prune_experiments(保留策略,默认 dry-run)、
     restore_experiment_from_job(从 Job 副本按原 id 重建被删的历史归档,默认 dry-run)

门禁:pytest 388 passed、ruff All checks passed、tsc 0 错误、图表单测 7 passed、
next build 成功、契约脚本 verify_strategy_workspace 59/59(含按 kind 逐类验证归档页)。
2026-09-20 07:31:04 +08:00

717 lines
27 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 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;
}
export function StrategyParamsForm({
value: p,
onChange,
factorOptions,
showMeta = false,
showPeriod = true,
disabled = false,
errors = {},
multiFactor = true,
}: StrategyParamsFormProps) {
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 })}
/>
{errors.description && (
<div className="field-err" role="alert">
{errors.description}
</div>
)}
</Field>
</div>
)}
{/* ---------- 因子(打分公式) ---------- */}
<div className="between" style={{ marginBottom: 8 }}>
<b style={{ fontSize: 13 }}>打分因子(score = Σ 权重 × 因子值,越大越优先)</b>
{multiFactor && (
<Btn
icon="layers"
disabled={disabled}
onClick={() => set({ factors: [...p.factors, { name: factorOptions[0]?.name ?? "", weight: 1 }] })}
>
添加因子
</Btn>
)}
</div>
<div className="row" style={{ flexWrap: "wrap", gap: 8, marginBottom: 6 }}>
{p.factors.map((f, i) => (
<div className="row" key={i} style={{ gap: 6 }}>
<select
className="input"
style={{ width: 220 }}
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
className="input mono"
style={{ width: 84 }}
type="number"
step="0.1"
value={f.weight}
disabled={disabled}
title="权重"
onChange={(e) => setFactor(i, { weight: Number(e.target.value) })}
/>
{multiFactor && p.factors.length > 1 && (
<Btn
icon="x"
disabled={disabled}
onClick={() => set({ factors: p.factors.filter((_, j) => j !== i) })}
>
删除
</Btn>
)}
</div>
))}
</div>
{errors.factors && (
<div className="field-err" role="alert">
{errors.factors}
</div>
)}
{/* ---------- 选股规模与周期 ---------- */}
<div className="form-grid" style={{ marginTop: 12 }}>
<Field label="候选池 n(择股条件选出股数)">
<input
className="input"
type="number"
min={1}
max={500}
value={p.topN}
disabled={disabled}
onChange={(e) => set({ topN: Number(e.target.value) })}
/>
</Field>
<Field label="持仓数 x(≤ n)">
<input
className={`input${errors.holdX ? " input--invalid" : ""}`}
type="number"
min={1}
max={p.topN}
value={p.holdX}
disabled={disabled}
onChange={(e) => set({ holdX: Number(e.target.value) })}
/>
{errors.holdX && (
<div className="field-err" role="alert">
{errors.holdX}
</div>
)}
</Field>
<Field label="择股间隔 m(月,0=跟随 y)" hint="m:多久重新挑一次股">
<input
className="input"
type="number"
min={0}
max={60}
value={p.mMonths}
disabled={disabled}
onChange={(e) => set({ mMonths: Number(e.target.value) })}
/>
{errors.mMonths && (
<div className="field-err" role="alert">
{errors.mMonths}
</div>
)}
</Field>
<Field label="调仓间隔 y(月,0=跟随 m)" hint="y:多久按最新选股结果换一次仓">
<input
className="input"
type="number"
min={0}
max={60}
value={p.yMonths}
disabled={disabled}
onChange={(e) => set({ yMonths: Number(e.target.value) })}
/>
{errors.yMonths && (
<div className="field-err" role="alert">
{errors.yMonths}
</div>
)}
</Field>
<Field label="调仓频率(m、y 均为 0 时生效)">
<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="后端两个字段互斥,这里用三选一表达,避免出现「都不生效」的静默组合"
>
<div className="radio-col">
{(
[
["substitute", "换一只买", "从候选池之外按复合分往下找可买标的,补足持仓数"],
["defer", "顺延买入", "等它到之后首个不涨停的交易日再按收盘价买入(到下次调仓仍未成交则作废)"],
["none", "不补位", "买不进就空着,实际持仓可能少于持仓数 x"],
] as const
).map(([val, label, desc]) => (
<label key={val} className="radio-row" title={desc}>
<input
type="radio"
name="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="标的范围">
<label className="row" style={{ gap: 6, color: "var(--text-2)", fontSize: 13, cursor: "pointer" }}>
<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"
min={0}
step={10}
value={p.minListingDays}
disabled={disabled}
onChange={(e) => set({ minListingDays: Number(e.target.value) })}
/>
</Field>
<Field label="手续费率 %">
<input
className="input"
type="number"
step="0.01"
min={0}
value={p.commission}
disabled={disabled}
onChange={(e) => set({ commission: Number(e.target.value) })}
/>
</Field>
<Field label="印花税率 %" hint="仅卖出收取">
<input
className="input"
type="number"
step="0.01"
min={0}
value={p.stamp}
disabled={disabled}
onChange={(e) => set({ stamp: Number(e.target.value) })}
/>
</Field>
<Field label="滑点率 %">
<input
className="input"
type="number"
step="0.01"
min={0}
value={p.slippage}
disabled={disabled}
onChange={(e) => set({ slippage: Number(e.target.value) })}
/>
</Field>
<Field label="最低佣金(元/笔)">
<input
className="input"
type="number"
step="1"
min={0}
value={p.minCommission}
disabled={disabled}
onChange={(e) => set({ minCommission: Number(e.target.value) })}
/>
{errors.minCommission && (
<div className="field-err" role="alert">
{errors.minCommission}
</div>
)}
</Field>
{showPeriod && (
<>
<Field label="初始资金(元)">
<input
className="input"
type="number"
step={100000}
min={10000}
value={p.capital}
disabled={disabled}
onChange={(e) => set({ capital: Number(e.target.value) })}
/>
{errors.capital && (
<div className="field-err" role="alert">
{errors.capital}
</div>
)}
</Field>
<Field label="开始日期">
<input
type="date"
className="input"
value={p.start}
disabled={disabled}
onChange={(e) => set({ start: e.target.value })}
/>
</Field>
<Field label="结束日期">
<input
type="date"
className="input"
value={p.end}
disabled={disabled}
onChange={(e) => set({ end: e.target.value })}
/>
</Field>
</>
)}
</div>
{errors.period && (
<div className="field-err" role="alert">
{errors.period}
</div>
)}
{/* ---------- 选股过滤条件 ---------- */}
<div style={{ marginTop: 14 }}>
<div className="between" style={{ marginBottom: 8 }}>
<b style={{ fontSize: 13 }}>选股过滤条件(AND,universe 之后、因子排序之前执行)</b>
<Btn
icon="layers"
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="row" style={{ flexWrap: "wrap", gap: 8 }}>
{p.conditions.map((c, i) => (
<div className="row" key={i} style={{ gap: 6 }}>
<input
className="input"
style={{ width: 190 }}
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"
style={{ width: 76 }}
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"
style={{ width: 110 }}
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}
onClick={() => set({ conditions: p.conditions.filter((_, j) => j !== i) })}
>
删除
</Btn>
</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>
</>
);
}