Files
qlib/frontend/web/app/strategies/page.tsx
T
Simon f13b34c59e feat(web): 字段库页 + 界面单位换算 + 因子目录参数化 + 选股策略条件表单
字段库与单位:
- `/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)。
2026-10-01 16:38:29 +08:00

437 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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;
}