"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 = { 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 & { config_snapshot?: Record }, base: StrategyParams ): StrategyParams { const snap = (spec.config_snapshot ?? {}) as Partial; const s = (snap.factors ? snap : spec) as Partial; const sel: NonNullable = s.selection ?? { top_n: base.topN }; const costs: NonNullable = 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 { const e: Record = {}; 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; /** 因子选择是否允许加权多项(默认允许) */ 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>({}); const blur = (key: string) => () => setTouched((t) => ({ ...t, [key]: true })); const showErr = (key: string) => (revealErrors || touched[key] ? errors[key] : undefined); const set = (patch: Partial) => 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 && (
set({ name: e.target.value })} /> {errors.name && (
{errors.name}
)}
set({ description: e.target.value })} /> {showErr("description") && (
{showErr("description")}
)}
)} {/* ---------- 因子(打分公式) ---------- 用「表头 + 栅格行」表达多项因子:列宽由 CSS 栅格统一控制(不再在 JSX 里写 内联宽度),这样因子下拉、权重输入、删除按钮在所有行严格成列对齐; 视觉表头给正常用户,aria-label 给读屏(重复行只有表头时读屏无法分辨)。 */}
打分因子(score = Σ 权重 × 因子值,越大越优先) {multiFactor && ( set({ factors: [...p.factors, { name: factorOptions[0]?.name ?? "", weight: 1 }] })} > 添加因子 )}
{p.factors.map((f, i) => (
setFactor(i, { weight: Number(e.target.value) })} /> {multiFactor && p.factors.length > 1 ? ( /* 与同行的下拉/输入同为 md 高度:行内控件必须等高,否则整行看起来是斜的 */ set({ factors: p.factors.filter((_, j) => j !== i) })} /> ) : ( )}
))}
权重 1 = 等权;只想用单个因子时把其它因子删掉即可。多个因子会先各自做横截面 z-score 标准化再加权,避免量纲不同互相压制。
{errors.factors && (
{errors.factors}
)} {/* ---------- 选股规模与周期 ---------- */}
set({ topN: Number(e.target.value) })} onBlur={blur("topN")} /> {showErr("topN") && (
{showErr("topN")}
)}
set({ holdX: Number(e.target.value) })} onBlur={blur("holdX")} /> {showErr("holdX") && (
{showErr("holdX")}
)}
set({ mMonths: Number(e.target.value) })} onBlur={blur("mMonths")} /> {showErr("mMonths") && (
{showErr("mMonths")}
)}
set({ yMonths: Number(e.target.value) })} onBlur={blur("yMonths")} /> {showErr("yMonths") && (
{showErr("yMonths")}
)}
{( [ ["substitute", "换一只买", "从候选池之外按复合分往下找可买标的,补足持仓数"], ["defer", "顺延买入", "等它到之后首个不涨停的交易日再按收盘价买入(到下次调仓仍未成交则作废)"], ["none", "不补位", "买不进就空着,实际持仓可能少于持仓数 x"], ] as const ).map(([val, label, desc]) => ( ))}
{/* ---------- 口径与成本 ---------- */}
set({ minListingDays: Number(e.target.value) })} /> set({ commission: Number(e.target.value) })} /> set({ stamp: Number(e.target.value) })} /> set({ slippage: Number(e.target.value) })} /> set({ minCommission: Number(e.target.value) })} onBlur={blur("minCommission")} /> {showErr("minCommission") && (
{showErr("minCommission")}
)}
{/* 费率类错误的行内落点:三个费率共用一个错误键(费率不能为负) */} {errors.costs && (
{errors.costs}
)} {showPeriod && ( <> set({ capital: Number(e.target.value) })} onBlur={blur("capital")} /> {showErr("capital") && (
{showErr("capital")}
)}
set({ start: e.target.value })} /> set({ end: e.target.value })} /> )}
{showErr("period") && (
{showErr("period")}
)} {/* ---------- 选股过滤条件 ---------- */}
选股过滤条件(AND,universe 之后、因子排序之前执行) set({ conditions: [...p.conditions, { field: "", op: "gte", value: 0 }] })} > 添加条件
{p.conditions.length === 0 ? (
未设置条件:候选池 = universe 内因子分最高的 n 只。
) : (
{p.conditions.map((c, i) => (
set({ conditions: p.conditions.map((x, j) => j === i ? { ...x, field: e.target.value } : x ), }) } /> { 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 ), }); }} /> set({ conditions: p.conditions.filter((_, j) => j !== i) })} />
))}
)} {errors.conditions && (
{errors.conditions}
)}
可用字段:每日指标 dv_ratio / dv_ttm / pe / pb / total_mv、行情 close / volume / amount、技术 ma20 / ma60、已注册因子名、static.industry 等、fundamental.roe 等 (财务按公告日 ≤ 择股日取用)。
); }