"use client"; /** * 选股策略表单(2026-09 重构):只编辑「选股条件组合」。 * * 与旧的 StrategyParamsForm 的区别:这里**没有**回测执行参数 —— * 资金 / 持仓数 / 持仓时间 / 调仓时机 / 费率 / 复权 / 区间都不属于选股策略, * 它们在「回测组合」(/backtest)里才填。策略库只回答一个问题:**怎么选**。 * * 字段:名字 + 一句话说明 + 股票池(剔ST/上市天数/指数成分/白名单)+ 因子[名,权重] + 过滤条件。 */ import Link from "next/link"; import { useId, useState } from "react"; import { factorOptionLabel } from "@/lib/factors"; import type { ConditionField, FactorMeta, ResearchCondition, SelectionStrategy } from "@/lib/types"; import { baseUnitOf, displayUnitOf, fromBase, toBase, unitScale } from "@/lib/units"; 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: "不属于" }, ]; const OP_LABEL: Record = Object.fromEntries(OPS.map((o) => [o.value, o.label])); /** 把「属于 / 不属于」的取值写成给人看的逗号分隔串(数组 value ↔ 文本)。 */ export function valueToText( v: ResearchCondition["value"], scale = 1, asNumber = true, ): string { if (v === null || v === undefined) return ""; // 存储值一律是**基准单位**;这里换算成界面单位显示(scale=1 时原样) const show = (x: unknown) => { if (!asNumber) return String(x); const n = Number(x); if (Number.isNaN(n)) return String(x); return String(fromBase(n, scale)); }; if (Array.isArray(v)) return v.map(show).join(", "); return show(v); } /** 文本 → 条件取值:属于/不属于 必为数组(引擎实体强制要求 list),其余按能否解析成数字。 * 数字按**界面单位**输入,写回时换算成基准单位(存储与引擎只用基准单位)。 */ export function textToValue( raw: string, op: Op, scale = 1, kind: "num" | "str" = "num", ): ResearchCondition["value"] { const conv = (s: string): string | number => { if (kind === "str") return s; const n = Number(s); return Number.isNaN(n) ? s : toBase(n, scale); }; if (op === "in" || op === "not_in") { return raw .split(/[,,]/) .map((s) => s.trim()) .filter(Boolean) .map(conv); } if (raw === "") return ""; return conv(raw); } export interface SelectionStrategyParams { name: string; description: string; factors: { name: string; weight: number }[]; conditions: ResearchCondition[]; excludeSt: boolean; minListingDays: number; indexCode: string; } export function emptySelectionParams(): SelectionStrategyParams { return { name: "", description: "", factors: [{ name: "dividend_yield", weight: 1 }], conditions: [], excludeSt: true, minListingDays: 250, indexCode: "", }; } /** 后端 SelectionStrategy → 表单参数 */ export function paramsFromSelectionStrategy(s: SelectionStrategy): SelectionStrategyParams { const u = s.universe ?? {}; return { name: s.name, description: s.description, factors: (s.factors ?? []).map((f) => ({ name: f.name, weight: f.weight })), conditions: (s.conditions ?? []).map((c) => ({ ...c })), excludeSt: u.exclude_st ?? true, minListingDays: u.min_listing_days ?? 250, indexCode: u.index_code ?? "", }; } /** 表单参数 → 后端 SelectionStrategy(id 可选,用于新建/更新) */ export function selectionStrategyFromParams( p: SelectionStrategyParams, id?: string ): SelectionStrategy { return { ...(id ? { id } : {}), name: p.name.trim(), description: p.description.trim(), universe: { exclude_st: p.excludeSt, min_listing_days: p.minListingDays, index_code: p.indexCode.trim() || null, }, factors: p.factors.filter((f) => f.name.trim()), conditions: p.conditions.filter((c) => c.field.trim()), }; } export function validateSelectionParams(p: SelectionStrategyParams): Record { const e: Record = {}; if (!p.name.trim()) e.name = "策略名必填(便于在策略库中识别)"; else if (p.name.trim().length > 64) e.name = "策略名最多 64 字"; if (!p.description.trim()) e.description = "一句话说明必填:说清这个策略怎么选"; 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 { 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}」重复了:同一因子只应出现一次(想加权重请调权重值)`; } for (const c of p.conditions) { if (!c.field.trim()) { e.conditions = "存在空的条件字段:请选择字段或删除该条件"; break; } if (c.ref !== null && c.ref !== undefined && !String(c.ref).trim()) { e.conditions = `条件「${c.field}」选择了「与另一字段比较」,但右侧字段是空的`; break; } if (c.ref) continue; // 字段 vs 字段:右侧字段已校验 if (c.op === "in" || c.op === "not_in") { if (!Array.isArray(c.value) || c.value.length === 0) { e.conditions = `条件「${c.field} ${OP_LABEL[c.op]}」需要至少一个取值(多个值用逗号分隔)`; break; } continue; } if (c.value === null || c.value === undefined || String(c.value).trim() === "") { e.conditions = `条件「${c.field}」缺少取值`; break; } } return e; } export interface SelectionStrategyFormProps { value: SelectionStrategyParams; onChange: (next: SelectionStrategyParams) => void; factorOptions: FactorMeta[]; /** 字段库(/api/condition-fields,只传启用项):条件的字段从这里选,附带中文名与含义。 */ conditionFields?: ConditionField[]; disabled?: boolean; errors?: Record; revealErrors?: boolean; } /** 该字段允许的比较符(字段库里没登记过的字段 → 按数值处理,交给后端报错)。 */ function opsFor(meta: ConditionField | undefined): Op[] { return (meta?.ops as Op[] | undefined) ?? ["gt", "gte", "lt", "lte", "eq", "ne"]; } /** 换字段/换比较符时把比较符收敛到合法集合,避免出现「行业 > 5」这种永远为假的组合。 */ function pickOp(current: Op, meta: ConditionField | undefined): Op { const allowed = opsFor(meta); if (allowed.includes(current)) return current; return meta?.kind === "str" ? "eq" : "gte"; } /** 取值随比较符变形:属于/不属于 必须是数组(引擎实体强制),其余退回标量。 */ function coerceValue(v: ResearchCondition["value"], op: Op): ResearchCondition["value"] { if (op === "in" || op === "not_in") { if (Array.isArray(v)) return v; return v === null || v === undefined || v === "" ? [] : [v as string | number]; } return Array.isArray(v) ? (v[0] ?? "") : v; } /** 因子方向的人话(与「因子研究」页同一套说法)。 */ function directionText(d: FactorMeta["direction"]): string { return d === "lower_is_better" ? "越低越好" : "越高越好"; } /** 因子频率的人话(后端存 daily/weekly/monthly,界面不该露出英文枚举)。 */ function frequencyText(f: string): string { return { daily: "日频", weekly: "周频", monthly: "月频" }[f] ?? f; } /** 选中因子后的说明:真实参数/方向/频率/回看/依赖列 + 简介(与字段库的口径提示同一位置)。 */ function factorHint(f: FactorMeta): string { const parts: string[] = []; // 真实参数(窗口等)放最前:这是「这个因子到底怎么算」的第一信息 const specs = f.param_specs ?? []; const params = specs .filter((s) => s.kind === "int") .map((s) => `${s.label} ${f.params?.[s.name] ?? s.default}`) .join("、"); if (params) parts.push(params); parts.push(directionText(f.direction), frequencyText(f.frequency)); parts.push(f.lookback ? `回看 ${f.lookback} 日` : "时点值(无回看窗口)"); if (f.requires?.length) parts.push(`需要 ${f.requires.join(" / ")}`); if (f.source === "custom") parts.push("目录里的参数化实例(参数写在名字里)"); return `${parts.join(" · ")} — ${f.brief || f.description}`; } export function SelectionStrategyForm({ value: p, onChange, factorOptions, conditionFields = [], disabled = false, errors = {}, revealErrors = false, }: SelectionStrategyFormProps) { const fid = useId(); 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)) }); // 字段库 → 分组下拉 + 按名查含义(后端已按 sort_order 排序,这里保持首次出现顺序) const fieldByName = new Map(conditionFields.map((f) => [f.name, f])); const fieldGroups: { name: string; items: ConditionField[] }[] = []; for (const f of conditionFields) { const g = fieldGroups.find((x) => x.name === f.group_name); if (g) g.items.push(f); else fieldGroups.push({ name: f.group_name, items: [f] }); } return (
{/* 名字 + 一句话说明 */}
set({ name: e.target.value })} onBlur={blur("name")} /> {showErr("name") &&
{showErr("name")}
}