"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 && ( /* 策略名(定宽)+ 一句话说明(占满剩余宽度、多行)并排: 说明最长 300 字,塞进单行 input 必然截断(截图实测「例:全市场股息率最高的 2」 就被切掉),所以这里用 textarea 并给足高度。 */
set({ name: e.target.value })} onBlur={blur("name")} /> {showErr("name") && (
{showErr("name")}
)}