字段库与单位: - `/fields`:字段库管理页(中文名/说明可改、可停用;kind 是引擎事实不可改); 单位只在字段自己的阶梯里选(总市值 = 万元/亿元),越界 422 原样展示。 - `lib/units.ts`:界面单位 ⇄ 基准单位换算集中一处,条件输入按界面单位回显、 提交前换回基准单位(引擎只认基准单位,库里存的也永远是基准单位)。 - `SelectionStrategyForm` 取代 `StrategyParamsForm`:一个策略只定义「怎么选」 (股票池 + 因子 + 过滤条件),条件字段来自字段库接口而不是前端硬编码枚举。 因子参数化(名字即身份,界面不许藏): - `/factors` 新增「参数」列与展开行:精确引擎键、每个参数的允许范围、来源、依赖列 (依赖列标明「引擎事实,不可改」);说明文案从「不可修改」改为 「改参数 = 新建参数化因子 = 新身份,旧因子/既有策略不变义」。 - 「新建参数化因子」卡片:模板下拉 + 受控窗口(min/max)+ 方向枚举下拉, 实时预览规范键/中文名/渲染公式;后端 422 的原文原样展示,不静默截断。 - 因子下拉显示中文名(含参数)与短键,**value 一律是完整引擎键**; 停用的因子从各页候选里消失(/strategies、/selection、/signals、/factors/compose), 既有策略/归档仍按名字解析;`/factors/compose` 顺手修了勾选框点击目标过小。 - `/fields` 加提示:字段库只列内置因子名,参数化因子在 /factors 管理, 选股策略条件下拉里会一并出现。 tsc --noEmit 0 error;verify_ui_alignment 8 页 160 项全过(另跑 3 个选因子页 51/54)。
437 lines
15 KiB
TypeScript
437 lines
15 KiB
TypeScript
"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<SelectionStrategy[] | null>(null);
|
||
const [factors, setFactors] = useState<FactorMeta[]>([]);
|
||
const [conditionFields, setConditionFields] = useState<ConditionField[]>([]);
|
||
/**
|
||
* 条件下拉用的字段表 = 字段库 + 因子目录里「字段库还没收录」的因子。
|
||
*
|
||
* 为什么:参数化因子(`momentum(window=90,direction=…)`)是引擎真认的过滤字段
|
||
* (`momentum_60 > 0` 一直合法),但它们不在字段库的注册表投影里。这里按名字去重地
|
||
* 并进来 —— 内置因子本来就在字段库的「因子」分组里,不会重复出现。
|
||
* 依赖列之类仍由字段库提供;因子条目的单位恒为空(因子是没有单位的无量纲量)。
|
||
*/
|
||
const filterFields = useMemo<ConditionField[]>(() => {
|
||
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<SelectionStrategyParams | null>(null);
|
||
const [editingId, setEditingId] = useState<string | null>(null);
|
||
const [revealErrors, setRevealErrors] = useState(false);
|
||
const [busy, setBusy] = useState(false);
|
||
const [docId, setDocId] = useState<string | null>(null);
|
||
|
||
const load = useCallback(async () => {
|
||
try {
|
||
const rows = await apiGet<SelectionStrategy[]>("/strategies");
|
||
setList(rows);
|
||
setErr("");
|
||
} catch (e) {
|
||
setErr((e as Error).message);
|
||
setList([]);
|
||
}
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
void load();
|
||
let alive = true;
|
||
apiGet<FactorMeta[]>("/factors")
|
||
.then((f) => alive && setFactors(f))
|
||
.catch(() => alive && setFactors([]));
|
||
// 字段库只取启用项:停用的字段不该出现在条件下拉里(管理在 /fields)
|
||
apiGet<ConditionField[]>("/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<SelectionStrategy>(`/strategies/${encodeURIComponent(editingId)}`, body);
|
||
setMsg(`已更新选股策略「${body.name}」`);
|
||
} else {
|
||
const saved = await apiPost<SelectionStrategy>("/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 (
|
||
<>
|
||
<PageHeader
|
||
title="选股策略库"
|
||
sub="每个策略只定义「怎么选」(股票池 + 因子 + 过滤条件)。要验证收益,去「回测组合」把它和一个或多个策略组合起来、填上回测参数再跑。"
|
||
actions={
|
||
<div className="row" style={{ gap: 8 }}>
|
||
<Link href="/backtest" className="btn">
|
||
<span>去建回测组合</span>
|
||
</Link>
|
||
<Btn variant="primary" icon="plus" onClick={startCreate} disabled={busy}>
|
||
新建选股策略
|
||
</Btn>
|
||
</div>
|
||
}
|
||
/>
|
||
|
||
{err ? <Banner tone="error">{err}</Banner> : null}
|
||
{msg ? <Banner tone="info">{msg}</Banner> : null}
|
||
|
||
{editing ? (
|
||
<Card
|
||
icon={editingId ? "edit" : "plus"}
|
||
title={editingId ? `编辑选股策略 · ${editingId}` : "新建选股策略"}
|
||
tools={
|
||
<div className="row" style={{ gap: 8 }}>
|
||
<Btn onClick={() => { setEditing(null); setEditingId(null); setDocId(null); }} disabled={busy}>
|
||
取消
|
||
</Btn>
|
||
<Btn variant="primary" icon="check" loading={busy} onClick={save} disabled={busy}>
|
||
{editingId ? "保存修改" : "保存到策略库"}
|
||
</Btn>
|
||
</div>
|
||
}
|
||
>
|
||
<SelectionStrategyForm
|
||
value={editing}
|
||
onChange={setEditing}
|
||
factorOptions={factors}
|
||
conditionFields={filterFields}
|
||
disabled={busy}
|
||
errors={validateSelectionParams(editing)}
|
||
revealErrors={revealErrors}
|
||
/>
|
||
<div className="hint" style={{ marginTop: 12 }}>
|
||
这里<b>不填</b>资金 / 持仓数 / 持仓时间 / 调仓时机 / 费率 / 复权 / 回测区间 ——
|
||
那些是回测时才定的,在「回测组合」里填;费率与复权在「公共配置」里设。
|
||
</div>
|
||
</Card>
|
||
) : null}
|
||
|
||
{editing ? <EditingDoc id={editingId} /> : null}
|
||
{docId && !editing ? <SavedDoc id={docId} onClose={() => setDocId(null)} /> : null}
|
||
|
||
<Card
|
||
icon="archive"
|
||
title={`选股策略${list ? ` · ${list.length}` : ""}`}
|
||
tools={
|
||
<input
|
||
className="input input--search"
|
||
placeholder="搜索策略名 / 说明 / 因子 / 条件"
|
||
value={query}
|
||
onChange={(e) => setQuery(e.target.value)}
|
||
aria-label="搜索选股策略"
|
||
/>
|
||
}
|
||
>
|
||
{filtered === null ? (
|
||
<Loading label="读取选股策略库…" />
|
||
) : filtered.length === 0 ? (
|
||
<Empty
|
||
icon="archive"
|
||
title={list && list.length ? "没有匹配的选股策略" : "选股策略库还是空的"}
|
||
hint={
|
||
list && list.length
|
||
? "换个关键词试试。"
|
||
: "点右上角「新建选股策略」保存第一个;定义好「怎么选」后,去回测组合里验证收益。"
|
||
}
|
||
/>
|
||
) : (
|
||
<div className="strategy-grid">
|
||
{filtered.map((s) => (
|
||
<StrategyCard
|
||
key={s.id ?? s.name}
|
||
s={s}
|
||
busy={busy}
|
||
fieldByName={fieldByName}
|
||
factorByKey={factorByKey}
|
||
onEdit={() => startEdit(s)}
|
||
onDelete={() => remove(s)}
|
||
onDoc={() => setDocId(s.id ?? null)}
|
||
/>
|
||
))}
|
||
</div>
|
||
)}
|
||
</Card>
|
||
</>
|
||
);
|
||
}
|
||
|
||
/* ------------------------------------------------------------------ */
|
||
|
||
function EditingDoc({ id }: { id: string | null }) {
|
||
// 编辑未保存时无法预览后端说明(需要 id);保存后才能看
|
||
const state = useStrategyDocById(id);
|
||
if (!id) {
|
||
return (
|
||
<Card icon="book" title="策略说明与计算公式">
|
||
<div className="hint">保存后即可在此预览后端按选股条件推导的说明与公式。</div>
|
||
</Card>
|
||
);
|
||
}
|
||
return (
|
||
<StrategyDocCard
|
||
doc={state.doc}
|
||
loading={state.loading}
|
||
error={state.error}
|
||
title="策略说明与计算公式(后端按选股条件推导)"
|
||
/>
|
||
);
|
||
}
|
||
|
||
function SavedDoc({ id, onClose }: { id: string; onClose: () => void }) {
|
||
const state = useStrategyDocById(id);
|
||
return (
|
||
<Card
|
||
icon="book"
|
||
title={`策略说明 · ${id}`}
|
||
tools={<Btn size="sm" onClick={onClose}>收起</Btn>}
|
||
>
|
||
{state.doc ? (
|
||
<StrategyDocBody doc={state.doc} />
|
||
) : (
|
||
<div className="hint">
|
||
{state.loading
|
||
? "正在生成说明与公式…"
|
||
: `说明暂不可用${state.error ? `:${state.error}` : "(后端 /api/strategies/{id}/describe 未就绪)"}。`}
|
||
</div>
|
||
)}
|
||
</Card>
|
||
);
|
||
}
|
||
|
||
function StrategyCard({
|
||
s,
|
||
busy,
|
||
fieldByName,
|
||
factorByKey,
|
||
onEdit,
|
||
onDelete,
|
||
onDoc,
|
||
}: {
|
||
s: SelectionStrategy;
|
||
busy: boolean;
|
||
fieldByName: Map<string, ConditionField>;
|
||
factorByKey: Map<string, FactorMeta>;
|
||
onEdit: () => void;
|
||
onDelete: () => void;
|
||
onDoc: () => void;
|
||
}) {
|
||
const u = s.universe ?? {};
|
||
return (
|
||
<div className="card" style={{ display: "flex", flexDirection: "column", gap: 10 }}>
|
||
<div className="between">
|
||
<div style={{ minWidth: 0 }}>
|
||
<div className="card__title" style={{ marginBottom: 2 }}>{s.name}</div>
|
||
<div className="row" style={{ gap: 6, flexWrap: "wrap" }}>
|
||
<span className="mono hint">{s.id}</span>
|
||
{s.created_at ? <span className="hint">创建 {String(s.created_at).slice(0, 10)}</span> : null}
|
||
</div>
|
||
</div>
|
||
<Pill tone="violet" icon="target">
|
||
{s.factors?.map((f) => factorLabel(factorByKey.get(f.name), f.name)).join(" + ") || "—"}
|
||
</Pill>
|
||
</div>
|
||
|
||
<div className="strategy-desc">{s.description || "(无说明:建议补一句话,便于日后识别)"}</div>
|
||
|
||
<div className="chips">
|
||
<span className="chip">
|
||
股票池 <b>{u.index_code ? `${u.index_code} 成分` : "全市场"}</b>
|
||
{u.exclude_st ? " · 剔 ST" : ""}
|
||
{u.min_listing_days ? ` · ≥${u.min_listing_days}天` : ""}
|
||
</span>
|
||
{(s.conditions ?? []).length ? (
|
||
<span className="chip">
|
||
条件{" "}
|
||
<b>
|
||
{(s.conditions ?? [])
|
||
.map((c) => `${fieldByName.get(c.field)?.label ?? c.field} ${opLabel(c.op)} ${condValueText(c, fieldByName.get(c.field))}`)
|
||
.join(" 且 ")}
|
||
</b>
|
||
</span>
|
||
) : (
|
||
<span className="chip">无过滤条件</span>
|
||
)}
|
||
</div>
|
||
|
||
<div className="strategy-actions">
|
||
<Link href={`/backtest?strategy=${encodeURIComponent(s.id ?? "")}`} className="btn btn--sm">
|
||
<span>加入回测组合</span>
|
||
</Link>
|
||
<Btn size="sm" icon="book" onClick={onDoc} disabled={busy}>
|
||
看说明/公式
|
||
</Btn>
|
||
<Btn size="sm" icon="edit" onClick={onEdit} disabled={busy}>
|
||
编辑
|
||
</Btn>
|
||
<Btn size="sm" variant="danger" icon="trash" onClick={onDelete} disabled={busy}>
|
||
删除
|
||
</Btn>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/**
|
||
* 卡片上的因子名:优先中文名(含参数,如「动量(窗口 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;
|
||
}
|
||
|