"use client"; /** * 选股策略库(/strategies)—— 只管理「选股条件组合」。 * * 2026-09 重构后这里**不再有回测参数**(资金/持仓/调仓/费率/区间都移到回测组合与公共配置)。 * 每个策略只回答「怎么选」:股票池 + 因子 + 过滤条件。要验证它,去 /backtest 把它 * (可与其他策略一起)放进一个回测组合再跑。 */ import Link from "next/link"; import { useCallback, useEffect, useMemo, useState } from "react"; import { apiDelete, apiGet, apiPost, apiPut } from "@/lib/api"; import type { ConditionField, FactorMeta, ResearchCondition, SelectionStrategy } from "@/lib/types"; import { displayUnitOf, fromBase, unitScale } from "@/lib/units"; import { Btn, Banner, Card, Empty, Loading, PageHeader, Pill, } from "@/components/ui"; import { SelectionStrategyForm, emptySelectionParams, paramsFromSelectionStrategy, selectionStrategyFromParams, validateSelectionParams, type SelectionStrategyParams, } from "@/components/SelectionStrategyForm"; import { StrategyDocBody, StrategyDocCard } from "@/components/StrategyDocCard"; import { useStrategyDocById } from "@/lib/strategy"; export default function StrategiesPage() { const [list, setList] = useState(null); const [factors, setFactors] = useState([]); const [conditionFields, setConditionFields] = useState([]); /** * 条件下拉用的字段表 = 字段库 + 因子目录里「字段库还没收录」的因子。 * * 为什么:参数化因子(`momentum(window=90,direction=…)`)是引擎真认的过滤字段 * (`momentum_60 > 0` 一直合法),但它们不在字段库的注册表投影里。这里按名字去重地 * 并进来 —— 内置因子本来就在字段库的「因子」分组里,不会重复出现。 * 依赖列之类仍由字段库提供;因子条目的单位恒为空(因子是没有单位的无量纲量)。 */ const filterFields = useMemo(() => { const known = new Set(conditionFields.map((f) => f.name)); const factorFields: ConditionField[] = factors .filter((f) => !known.has(f.name) && f.enabled !== false && f.resolvable !== false) .map((f) => ({ name: f.name, label: f.label || f.name, description: `${f.description}${f.brief ? ` 用法:${f.brief}` : ""}`, kind: "num", group_name: "因子", unit: "", source: f.source === "custom" ? "custom" : "builtin", enabled: true, sort_order: 900, ops: ["gt", "gte", "lt", "lte", "eq", "ne"], })); return [...conditionFields, ...factorFields]; }, [conditionFields, factors]); /** 字段名 → 字段库条目:卡片回显条件时据此取中文名与界面单位。 */ const fieldByName = useMemo( () => new Map(filterFields.map((f) => [f.name, f])), [filterFields], ); /** 因子名 → 目录条目:卡片/Pill 显示中文名(含参数),而不是又长又生的引擎键。 */ const factorByKey = useMemo(() => new Map(factors.map((f) => [f.name, f])), [factors]); const [query, setQuery] = useState(""); const [err, setErr] = useState(""); const [msg, setMsg] = useState(""); const [editing, setEditing] = useState(null); const [editingId, setEditingId] = useState(null); const [revealErrors, setRevealErrors] = useState(false); const [busy, setBusy] = useState(false); const [docId, setDocId] = useState(null); const load = useCallback(async () => { try { const rows = await apiGet("/strategies"); setList(rows); setErr(""); } catch (e) { setErr((e as Error).message); setList([]); } }, []); useEffect(() => { void load(); let alive = true; apiGet("/factors") .then((f) => alive && setFactors(f)) .catch(() => alive && setFactors([])); // 字段库只取启用项:停用的字段不该出现在条件下拉里(管理在 /fields) apiGet("/condition-fields?include_disabled=false") .then((f) => alive && setConditionFields(f)) .catch(() => alive && setConditionFields([])); return () => { alive = false; }; }, [load]); const filtered = useMemo(() => { if (!list) return null; const q = query.trim().toLowerCase(); if (!q) return list; return list.filter((s) => { const hay = [ s.name, s.description, (s.factors ?? []).map((f) => f.name).join(" "), (s.conditions ?? []).map((c) => c.field).join(" "), ] .join(" ") .toLowerCase(); return hay.includes(q); }); }, [list, query]); function startCreate() { setEditingId(null); setEditing(emptySelectionParams()); setDocId(null); setMsg(""); setErr(""); setRevealErrors(false); } function startEdit(s: SelectionStrategy) { setEditingId(s.id ?? null); setEditing(paramsFromSelectionStrategy(s)); setDocId(s.id ?? null); setMsg(""); setErr(""); setRevealErrors(false); } async function save() { if (!editing) return; const errors = validateSelectionParams(editing); if (Object.keys(errors).length) { setErr(Object.values(errors)[0]); setRevealErrors(true); return; } setBusy(true); setErr(""); try { const body = selectionStrategyFromParams(editing, editingId ?? undefined); if (editingId) { await apiPut(`/strategies/${encodeURIComponent(editingId)}`, body); setMsg(`已更新选股策略「${body.name}」`); } else { const saved = await apiPost("/strategies", body); setMsg(`已保存选股策略「${saved.name}」(${saved.id})`); } setEditing(null); setEditingId(null); setDocId(null); await load(); } catch (e) { setErr((e as Error).message); } finally { setBusy(false); } } async function remove(s: SelectionStrategy) { if (!s.id) return; if (!window.confirm(`删除选股策略「${s.name}」?引用它的回测组合将无法运行。`)) return; setBusy(true); try { await apiDelete(`/strategies/${encodeURIComponent(s.id)}`); setMsg(`已删除选股策略「${s.name}」`); await load(); } catch (e) { setErr((e as Error).message); } finally { setBusy(false); } } return ( <> 去建回测组合 新建选股策略 } /> {err ? {err} : null} {msg ? {msg} : null} {editing ? ( { setEditing(null); setEditingId(null); setDocId(null); }} disabled={busy}> 取消 {editingId ? "保存修改" : "保存到策略库"} } >
这里不填资金 / 持仓数 / 持仓时间 / 调仓时机 / 费率 / 复权 / 回测区间 —— 那些是回测时才定的,在「回测组合」里填;费率与复权在「公共配置」里设。
) : null} {editing ? : null} {docId && !editing ? setDocId(null)} /> : null} setQuery(e.target.value)} aria-label="搜索选股策略" /> } > {filtered === null ? ( ) : filtered.length === 0 ? ( ) : (
{filtered.map((s) => ( startEdit(s)} onDelete={() => remove(s)} onDoc={() => setDocId(s.id ?? null)} /> ))}
)}
); } /* ------------------------------------------------------------------ */ function EditingDoc({ id }: { id: string | null }) { // 编辑未保存时无法预览后端说明(需要 id);保存后才能看 const state = useStrategyDocById(id); if (!id) { return (
保存后即可在此预览后端按选股条件推导的说明与公式。
); } return ( ); } function SavedDoc({ id, onClose }: { id: string; onClose: () => void }) { const state = useStrategyDocById(id); return ( 收起} > {state.doc ? ( ) : (
{state.loading ? "正在生成说明与公式…" : `说明暂不可用${state.error ? `:${state.error}` : "(后端 /api/strategies/{id}/describe 未就绪)"}。`}
)}
); } function StrategyCard({ s, busy, fieldByName, factorByKey, onEdit, onDelete, onDoc, }: { s: SelectionStrategy; busy: boolean; fieldByName: Map; factorByKey: Map; onEdit: () => void; onDelete: () => void; onDoc: () => void; }) { const u = s.universe ?? {}; return (
{s.name}
{s.id} {s.created_at ? 创建 {String(s.created_at).slice(0, 10)} : null}
{s.factors?.map((f) => factorLabel(factorByKey.get(f.name), f.name)).join(" + ") || "—"}
{s.description || "(无说明:建议补一句话,便于日后识别)"}
股票池 {u.index_code ? `${u.index_code} 成分` : "全市场"} {u.exclude_st ? " · 剔 ST" : ""} {u.min_listing_days ? ` · ≥${u.min_listing_days}天` : ""} {(s.conditions ?? []).length ? ( 条件{" "} {(s.conditions ?? []) .map((c) => `${fieldByName.get(c.field)?.label ?? c.field} ${opLabel(c.op)} ${condValueText(c, fieldByName.get(c.field))}`) .join(" 且 ")} ) : ( 无过滤条件 )}
加入回测组合 看说明/公式 编辑 删除
); } /** * 卡片上的因子名:优先中文名(含参数,如「动量(窗口 90,越高越好)」)。 * * 参数化因子的引擎键很长(`momentum(window=90,direction=higher_is_better)`), * 卡片上直接显示会把布局撑坏;找不到目录条目时退回键名 —— 预览不到就照实显示, * 不猜、不截断成看起来像另一个因子。 */ function factorLabel(meta: FactorMeta | undefined, name: string): string { return meta?.label || name; } function opLabel(op: string): string { return ( { gt: ">", gte: "≥", lt: "<", lte: "≤", eq: "=", ne: "≠", in: "属于", not_in: "不属于" }[op] ?? op ); } /** 条件取值的人类可读文本:存的是**基准单位**,这里按字段库的界面单位回显。 * * 卡片上必须带单位(如「≥ 5 亿元」):引擎按基准单位比较,用户看到的若是裸数字, * 就会把「5 亿元」读成「5 万元」—— 这正是「单位可选」要避免的误读。 */ function condValueText(c: ResearchCondition, meta?: ConditionField): string { if (c.ref) return `字段 ${c.ref}`; const scale = unitScale(meta); const unit = displayUnitOf(meta) || (meta?.base_unit ?? meta?.unit ?? ""); const show = (x: unknown) => { const n = Number(x); return Number.isNaN(n) ? String(x ?? "") : String(fromBase(n, scale)); }; const body = Array.isArray(c.value) ? c.value.map(show).join("、") : show(c.value); return unit ? `${body} ${unit}` : body; }