|
- togglePick(f.name)}
- aria-label={`选择因子 ${f.name}`}
- />
+ {/* 勾选框要用 .check--cell 包一层:裸 16px 的 checkbox 点击目标太小
+ (本项目 UI 自检要求 ≥28px,且与同排输入框等高) */}
+
|
- {f.name}
+ {factorLabel(f)}
+ {/* 名字即身份:中文名旁边的引擎键必须看得见(含参数,如 momentum(window=90,…)) */}
+
+ {f.name}
+
|
diff --git a/frontend/web/app/factors/page.tsx b/frontend/web/app/factors/page.tsx
index 5d11e6b..74beeb2 100644
--- a/frontend/web/app/factors/page.tsx
+++ b/frontend/web/app/factors/page.tsx
@@ -1,9 +1,15 @@
"use client";
-import { useEffect, useState } from "react";
-import { apiGet } from "@/lib/api";
+import { useCallback, useEffect, useState } from "react";
+import { apiGet, apiPatch, apiPost } from "@/lib/api";
import { submitJob, waitJob } from "@/lib/jobs";
-import type { FactorMeta, FactorTestReport, ResearchSpec } from "@/lib/types";
+import type {
+ FactorMeta,
+ FactorParam,
+ FactorTemplate,
+ FactorTestReport,
+ ResearchSpec,
+} from "@/lib/types";
import { recentRange } from "@/lib/dates";
import {
PageHeader,
@@ -18,8 +24,156 @@ import {
SkeletonLines,
} from "@/components/ui";
+/**
+ * 因子研究页。
+ *
+ * 参数化(2026-10 后端能力)之后,这个页面的诚实性要求变了:参数(窗口 / 方向)**可以改**,
+ * 但改的方式是「从模板新建一个参数化因子」—— 参数写进因子名,名字即身份。所以本页要
+ * ① 说清「改参数 = 新身份,旧因子与既有策略不变义」;② 把每行的真实参数摆出来;
+ * ③ 给一个受控的新建表单(范围来自 param_specs,越界由后端 422 拒绝,界面不静默纠正)。
+ */
+
+/** 方向枚举的中文说法:界面上不暴露 higher_is_better 这种原始值。 */
+const DIRECTION_TEXT: Record = {
+ higher_is_better: "越高越好",
+ lower_is_better: "越低越好",
+};
+
+/** 参数原始值 → 展示值(方向枚举翻译成中文,其余原样;缺值退回模板默认值)。 */
+function paramValueText(spec: FactorParam, raw: number | string | undefined | null): string {
+ const value = raw === undefined || raw === null ? spec.default : raw;
+ if (typeof value === "string" && DIRECTION_TEXT[value]) return DIRECTION_TEXT[value];
+ return String(value);
+}
+
+/**
+ * 参数原始值 → **写进名字里的字面值**(整数写数字、枚举写 higher_is_better)。
+ *
+ * 与 paramValueText 分开的原因:名字是引擎键,必须原样;中文只用于给人看的文案。
+ */
+function paramRawText(spec: FactorParam, raw: number | string | undefined | null): string {
+ const value = raw === undefined || raw === null ? spec.default : raw;
+ return String(value);
+}
+
+/** 参数允许范围 / 枚举文案:展开行与新建表单共用一份,避免两处说法分叉。 */
+function paramRangeText(spec: FactorParam): string {
+ if (spec.kind === "int") {
+ const { minimum: min, maximum: max } = spec;
+ if (min !== undefined && min !== null && max !== undefined && max !== null) {
+ return `${min} ~ ${max} 的整数`;
+ }
+ return "整数";
+ }
+ const choices = spec.choices ?? [];
+ if (choices.length === 0) return "受控枚举";
+ return choices.map((c) => DIRECTION_TEXT[c] ?? c).join(" / ");
+}
+
+/** 单个参数的「标签 + 值」片段;方向这类不重复写标签(值本身已经是中文说法)。 */
+function paramPartText(spec: FactorParam, raw: number | string | undefined | null): string {
+ const value = paramValueText(spec, raw);
+ return spec.name === "direction" ? value : `${spec.label} ${value}`;
+}
+
+/**
+ * 目录里的「参数」摘要:`窗口 60 · 越高越好`。
+ *
+ * 没有 int 参数的因子是逐日时点值(如股息率),补一句「时点值(无窗口)」——
+ * 否则「没有窗口」和「窗口是 0」在界面上分不出来。
+ */
+function paramSummary(f: FactorMeta): string {
+ const specs = f.param_specs ?? [];
+ if (specs.length === 0) return f.resolvable === false ? "无参数声明" : "无参数";
+ const params = f.params ?? {};
+ const parts = specs.map((s) => paramPartText(s, params[s.name]));
+ if (!specs.some((s) => s.kind === "int")) parts.unshift("时点值(无窗口)");
+ return parts.join(" · ");
+}
+
+/**
+ * 来源文案。
+ *
+ * 为什么先判 resolvable:库里算不出来的历史手登记行,`source` 只是 DB 默认值 builtin,
+ * 直接读它会把它说成「内置实例」——那是假话。
+ */
+function sourceText(f: FactorMeta): string {
+ if (f.resolvable === false) return "历史手工登记行";
+ return f.source === "custom" ? "目录里的参数化实例" : "内置实例";
+}
+
+/** 界面显示名:优先后端给的中文名(含参数),没有才退回引擎键。 */
+function factorLabel(f: FactorMeta): string {
+ return f.label || f.name;
+}
+
+/** 新建表单初值:模板默认值,并以 param_specs 的 default 兜底(defaults 缺项也不空着)。 */
+function defaultsOf(t: FactorTemplate): Record {
+ const out: Record = {};
+ for (const s of t.param_specs) out[s.name] = t.defaults[s.name] ?? s.default;
+ return out;
+}
+
+/**
+ * 预览因子键:严格按 param_specs 的顺序拼 `模板名(参数=值,...)`(方向天然在最后)。
+ *
+ * 与后端 canonical_key 同一套形状 —— 预览错了就等于骗人,所以这里不做任何本地化/重排。
+ */
+function previewKey(t: FactorTemplate, values: Record): string {
+ const args = t.param_specs.map((s) => `${s.name}=${paramRawText(s, values[s.name])}`);
+ return `${t.name}(${args.join(",")})`;
+}
+
+/** 预览中文名:镜像后端 _default_label(非方向参数用「、」,方向用「,」接在最后)。 */
+function previewLabel(t: FactorTemplate, values: Record): string {
+ const parts: string[] = [];
+ let direction = "";
+ for (const s of t.param_specs) {
+ const value = paramValueText(s, values[s.name]);
+ if (s.name === "direction") direction = value;
+ else parts.push(`${s.label} ${value}`);
+ }
+ const inner = [parts.join("、"), direction].filter(Boolean).join(",");
+ return `${t.label}(${inner})`;
+}
+
+/**
+ * 模板公式里的 `{参数}` 占位符按**当前表单值**渲染(镜像后端 _render)。
+ *
+ * 为什么不能在预览里直接摆模板原文:模板公式写的是 `close / close.shift({window}) - 1`,
+ * 而真实因子的公式是 `…shift(90)…` —— 摆原文会让预览和创建后的口径看起来不一致。
+ */
+function renderFormula(t: FactorTemplate, values: Record): string {
+ return t.formula.replace(/\{(\w+)\}/g, (raw, key: string) => {
+ const spec = t.param_specs.find((s) => s.name === key);
+ return spec ? paramRawText(spec, values[key]) : raw;
+ });
+}
+
+/**
+ * 从 lib/api 抛出的错误里取出后端的 `detail` 原文。
+ *
+ * 为什么:apiPost 的报错是 `POST /factors → 422: {"detail":"…"}` 整串文本,而 422 的 detail
+ * 已经是给人看的中文(含允许范围、已有重名)—— 必须原样转述;界面自己再写一遍错误文案,
+ * 就会和后端的受控范围说法分叉。
+ */
+function apiDetail(e: unknown): string {
+ const raw = e instanceof Error ? e.message : String(e);
+ const start = raw.indexOf("{");
+ if (start >= 0) {
+ try {
+ const body = JSON.parse(raw.slice(start)) as { detail?: unknown };
+ if (typeof body.detail === "string" && body.detail) return body.detail;
+ } catch {
+ /* 响应体不是 JSON(如 500 的纯文本):退回整串信息,至少不丢状态码 */
+ }
+ }
+ return raw;
+}
+
export default function FactorsPage() {
const [factors, setFactors] = useState([]);
+ const [templates, setTemplates] = useState([]);
const [loading, setLoading] = useState(true);
const [checked, setChecked] = useState>(new Set());
const [expanded, setExpanded] = useState>(new Set());
@@ -31,6 +185,26 @@ export default function FactorsPage() {
const [current, setCurrent] = useState<{ idx: number; total: number; name: string } | null>(null);
const [jobId, setJobId] = useState("");
const [error, setError] = useState("");
+ /** 目录级成功提示(停用 / 启用)。 */
+ const [notice, setNotice] = useState("");
+
+ // 新建参数化因子表单
+ const [tplName, setTplName] = useState("");
+ const [paramValues, setParamValues] = useState>({});
+ const [creating, setCreating] = useState(false);
+ const [created, setCreated] = useState<{ name: string; label?: string } | null>(null);
+ const [createErr, setCreateErr] = useState("");
+ /** 模板清单是否还在加载:与目录分开,避免「还没加载完」被说成「没加载出来」。 */
+ const [tplLoading, setTplLoading] = useState(true);
+ /** 正在切换开关的因子名(只禁用那一行,不冻结全表)。 */
+ const [toggling, setToggling] = useState("");
+
+ /** 只刷新目录列表:不动勾选 / 展开状态(新建或停用后刷新不该清掉用户的选择)。 */
+ const refresh = useCallback(async () => {
+ const list = await apiGet("/factors");
+ setFactors(list);
+ return list;
+ }, []);
useEffect(() => {
let alive = true;
@@ -43,8 +217,22 @@ export default function FactorsPage() {
);
setChecked(new Set(picks));
})
- .catch((e: Error) => setError(e.message))
+ .catch((e: Error) => alive && setError(e.message))
.finally(() => alive && setLoading(false));
+ // 模板清单单独取:它挂了只影响「新建参数化因子」卡片,不该把整个目录一起变成错误页
+ apiGet("/factors/templates")
+ .then((tpls) => {
+ if (!alive) return;
+ setTemplates(tpls);
+ // 默认落在动量模板(最常见的例子)上,没有就取第一个
+ const t = tpls.find((x) => x.name === "momentum") ?? tpls[0];
+ if (t) {
+ setTplName(t.name);
+ setParamValues(defaultsOf(t));
+ }
+ })
+ .catch((e: Error) => alive && setCreateErr(e.message))
+ .finally(() => alive && setTplLoading(false));
const { start: s, end: e } = recentRange();
setStart(s);
setEnd(e);
@@ -71,6 +259,60 @@ export default function FactorsPage() {
});
}
+ function pickTemplate(name: string) {
+ setTplName(name);
+ setCreateErr("");
+ setCreated(null);
+ const t = templates.find((x) => x.name === name);
+ setParamValues(t ? defaultsOf(t) : {});
+ }
+
+ function setParam(name: string, value: number | string) {
+ setParamValues((prev) => ({ ...prev, [name]: value }));
+ }
+
+ async function createFactor() {
+ const tpl = templates.find((x) => x.name === tplName);
+ if (!tpl) {
+ setCreateErr("请先选择一个模板");
+ return;
+ }
+ setCreating(true);
+ setCreateErr("");
+ setCreated(null);
+ try {
+ // 参数值原样提交(整数就是数字/数字串,枚举就是原始值):受控校验只由后端做,
+ // 越界时转述 422 的 detail,界面不自己截断也不自己改写错误
+ const row = await apiPost("/factors", { template: tpl.name, params: paramValues });
+ setCreated({ name: row.name, label: row.label });
+ await refresh();
+ } catch (e) {
+ setCreateErr(apiDetail(e));
+ } finally {
+ setCreating(false);
+ }
+ }
+
+ async function toggleEnabled(f: FactorMeta) {
+ setToggling(f.name);
+ setNotice("");
+ setError("");
+ try {
+ const row = await apiPatch("/factors", { name: f.name, enabled: !f.enabled });
+ setNotice(
+ row.enabled
+ ? `因子 ${row.name} 已启用,重新出现在选择列表里。`
+ : `因子 ${row.name} 已停用:它仍留在目录里(既有策略 / 归档仍按它的名字解析),只是不再出现在选择列表里。`,
+ );
+ // 停用后照样刷新而不是本地隐藏 —— 停用的行必须还在目录里看得见
+ await refresh();
+ } catch (e) {
+ setError(apiDetail(e));
+ } finally {
+ setToggling("");
+ }
+ }
+
async function run() {
const names = factors.map((f) => f.name).filter((n) => checked.has(n));
if (names.length === 0) {
@@ -119,6 +361,9 @@ export default function FactorsPage() {
const reportNames = Object.keys(reports);
const progressPct = selectedCount === 0 ? 0 : Math.round((processed / selectedCount) * 100);
+ const tpl = templates.find((x) => x.name === tplName) ?? null;
+ const tplSpecs = tpl?.param_specs ?? [];
+
return (
<>
{error}
) : null}
+ {notice && !running ? {notice} : null}
已选 {selectedCount} 个}
flush
>
+ {/* 目录的性质说明:放在 loading 分支之外,加载中也能看到(也是 SSR 可断言的静态文案)。
+ 参数化之后这里必须说清新分工:口径文案仍以代码为准,参数则靠「新建参数化因子」来改。
+ overflowWrap:示例键里没有空格,窄屏必须能断行,否则整页会横向溢出。 */}
+
+ 目录是代码注册表(quant/factors.py)的投影:
+ 口径文案(描述 / 公式 / 方向 / 回看)由引擎决定并自动同步(手改会在下次读取时被纠正回代码文本)。
+ 参数(窗口、方向)可以改,改的方式是「从模板新建一个参数化因子」——
+ 参数写进因子的名字里(如 momentum(window=90,direction=higher_is_better)),
+ 所以新因子是一个新身份:旧因子、既有策略与归档都按各自名字里的参数计算,
+ 不会变义。参数只在模板给定的受控范围内可选 / 可填,越界会被后端拒绝(不会静默截断成边界值)。
+ 依赖列(requires)仍不可改 —— 它是「引擎能不能算」的事实,不是配置。
+
{loading ? (
@@ -158,9 +416,11 @@ export default function FactorsPage() {
|
因子 |
+ 参数 |
回看 |
方向 |
简介(用法 / 何时有效) |
+ 操作 |
@@ -170,8 +430,10 @@ export default function FactorsPage() {
factor={f}
checked={checked.has(f.name)}
expanded={expanded.has(f.name)}
+ toggling={toggling === f.name}
onToggleCheck={() => toggleCheck(f.name)}
onToggleExpand={() => toggleExpand(f.name)}
+ onToggleEnabled={() => void toggleEnabled(f)}
/>
))}
@@ -184,6 +446,139 @@ export default function FactorsPage() {
)}
+ 创建中 : undefined}
+ >
+ {tplLoading ? (
+
+ ) : templates.length === 0 ? (
+
+
+ 模板清单没加载出来(GET /api/factors/templates 未返回模板),
+ 所以暂时无法新建参数化因子;目录本身不受影响。
+
+ {createErr ? {createErr} : null}
+
+ ) : (
+
+
+
+
+
+
+ {/* 受控表单:参数从模板 param_specs 来,顺序也照它(方向恒在最后) */}
+ {tplSpecs.map((s) =>
+ s.kind === "int" ? (
+
+ setParam(s.name, e.target.value)}
+ />
+
+ ) : (
+
+
+
+ ),
+ )}
+
+
+ 新建参数化因子
+
+
+
+ {tpl ? (
+ /* overflowWrap:预览键是长且无空格的引擎键,窄屏要能断行 */
+
+ 创建预览
+
+ 因子键:{previewKey(tpl, paramValues)}
+ {" "}(参数顺序与模板一致,方向恒在最后;这个键就是身份)
+
+
+ 中文名:{previewLabel(tpl, paramValues)}
+ {" "}· 公式 {renderFormula(tpl, paramValues)}
+
+
+ 中文名按引擎的通用规则预览(量比这类有定制命名的模板,创建后以引擎返回的名字为准)。
+
+
+ ) : null}
+
+ {created ? (
+
+ 已创建因子 {created.name}
+ {created.label ? <>({created.label})> : null}:参数已经写进名字里,
+ 这是一个新身份 —— 旧因子与既有策略不变义。
+
+ ) : null}
+ {createErr ? {createErr} : null}
+
+
+ 参数相同不会重复创建:同一模板、同一组参数只对应一个因子键,重复提交会收到后端的重名提示。
+ 要换参数就再建一个(新键),不要指望改旧键 —— 旧键被改了,引用它的策略与归档就会变义。
+
+
+ )}
+
+
执行中 : undefined}>
@@ -238,8 +633,16 @@ export default function FactorsPage() {
+ {name} · {meta.brief}
+
+ ) : (
+ meta?.brief
+ )
+ }
tools={
已完成
@@ -258,13 +661,23 @@ function FactorRow(props: {
factor: FactorMeta;
checked: boolean;
expanded: boolean;
+ toggling: boolean;
onToggleCheck: () => void;
onToggleExpand: () => void;
+ onToggleEnabled: () => void;
}) {
const { factor: f } = props;
+ const label = factorLabel(f);
+ // 开关只对「目录里创建的参数化实例」开放:内置实例的开关由代码决定(后端会 422),
+ // 算不出来的历史行也不给开关(后端拒绝,且开关本来就没有意义)
+ const canToggle = f.source === "custom" && f.resolvable !== false;
+ const high = f.direction === "higher_is_better";
return (
<>
-
+
| e.stopPropagation()}>
|
- {f.name}
+ {label}
+ {/* 名字就是身份:中文名旁边的引擎键必须看得见(含参数,如 momentum(window=90,…)) */}
+ {f.label && f.label !== f.name ? (
+
+ {f.name}
+
+ ) : null}
+ {f.enabled === false ? <> 已停用> : null}
+ {f.resolvable === false ? <> 引擎算不出来> : null}
|
+ {paramSummary(f)} |
{f.lookback} 日
|
-
- {f.direction === "higher_is_better" ? "高为好" : "低为好"}
-
+ {/* 算不出来的历史行没有可信方向(direction 只是 DB 默认值),不摆一个假的方向 */}
+ {f.resolvable === false ? (
+ —
+ ) : (
+ {high ? "越高越好" : "越低越好"}
+ )}
|
{f.brief ?? f.description} |
+ e.stopPropagation()}>
+ {canToggle ? (
+
+ {f.enabled === false ? "启用" : "停用"}
+
+ ) : (
+
+ 停用
+
+ )}
+ |
{props.expanded ? (
-
-
- {f.name} · {f.description}
-
- 公式: {f.formula};回看 {f.lookback} 个交易日;频率 {f.frequency};
- 方向:
- {f.direction === "higher_is_better" ? "因子值越高得分越高" : "因子值越低得分越高(引擎自动反向)"}
+
+
+ {/* 展开行里也会出现长参数键,窄屏同样要能断行 */}
+
+ {f.name} · {f.description}
- {f.brief ? {f.brief} : null}
+
+
+ 参数(写进名字里的身份)
+ {(f.param_specs ?? []).length > 0 ? (
+ (f.param_specs ?? []).map((s) => (
+
+ {s.name}
+ {paramValueText(s, (f.params ?? {})[s.name])}
+ {/* 枚举参数的 note 往往已把可选值抄了一遍(如方向),就不再重复一次 */}
+
+ {s.kind === "int"
+ ? `允许 ${paramRangeText(s)}${s.note ? ` · ${s.note}` : ""}`
+ : `可选 ${paramRangeText(s)}${
+ s.note && !s.note.includes(paramRangeText(s)) ? ` · ${s.note}` : ""
+ }`}
+
+
+ ))
+ ) : (
+
+ 该因子没有声明可编辑参数{f.resolvable === false ? "(历史手工登记行)" : "(时点值口径,无窗口)"}。
+
+ )}
+
+
+
+ 公式:{f.formula};频率 {f.frequency};回看 {f.lookback} 个交易日;
+ 方向:
+ {f.resolvable === false
+ ? "未知(引擎算不出来)"
+ : high
+ ? "因子值越高得分越高"
+ : "因子值越低得分越高(引擎自动反向)"}
+
+
+ 依赖列:
+ {f.requires && f.requires.length > 0 ? (
+ {f.requires.join(" / ")}
+ ) : (
+ "无"
+ )}
+ (引擎能不能算的事实,不可改)
+
+
+ {sourceText(f)}
+ {f.template ? (
+
+ 模板 {f.template}
+
+ ) : null}
+
+ {f.enabled === false ? "已停用" : "启用中"}
+
+ {f.enabled === false ? (
+ 既有策略 / 归档仍按它的名字解析,停用只是不出现在选择列表里。
+ ) : null}
+
+ {f.resolvable === false ? (
+
+ 引擎算不出来(历史手工登记行),引用时会报错:这不是「配置不一致」,
+ 而是代码注册表里没有能解析它的模板 / 参数。
+
+ ) : null}
+ {f.brief ? {f.brief} : null}
|
|
@@ -365,4 +877,4 @@ function ReportView({ report }: { report: FactorTestReport }) {
);
-}
+}
\ No newline at end of file
diff --git a/frontend/web/app/fields/page.tsx b/frontend/web/app/fields/page.tsx
new file mode 100644
index 0000000..0c3c2df
--- /dev/null
+++ b/frontend/web/app/fields/page.tsx
@@ -0,0 +1,435 @@
+"use client";
+
+/**
+ * 字段库(/fields)—— 过滤条件可用字段的目录与维护页。
+ *
+ * 解决三个问题(用户 2026-10 反馈):
+ * 1. 过滤条件的字段此前要**手填**(dv_ratio / static.industry …),看不到含义、写错不报错;
+ * 现在字段有中文名 + 口径说明 + 单位 + 类型,条件编辑器里是分组下拉。
+ * 2. 字段库可增减、可编辑:改中文名/含义/单位/分组,停用不用的,新增少用但引擎支持的。
+ * 3. 说清「打分因子 vs 过滤条件」的关系 —— 见下方说明卡。
+ *
+ * 诚实性约束:能加什么由**引擎**决定(后端校验)。加一个引擎算不出来的字段,条件会
+ * 永远不通过 —— 所以这里只允许从「引擎支持但尚未进库」的清单里挑,不允许手填乱造。
+ */
+import { useCallback, useEffect, useState } from "react";
+import Link from "next/link";
+import { apiDelete, apiGet, apiPost, apiPut } from "@/lib/api";
+import type { ConditionField, ConditionFieldOption } from "@/lib/types";
+import { factorOf } from "@/lib/units";
+import { Banner, Btn, Card, Empty, Field, Loading, PageHeader, Pill } from "@/components/ui";
+
+/** 编辑中的行(null=未编辑)。 */
+type Draft = {
+ label: string;
+ description: string;
+ unit: string;
+ group_name: string;
+ enabled: boolean;
+};
+
+const KIND_LABEL: Record = { num: "数值", str: "文本" };
+
+/** 单位选择器:**只能从注册表给的阶梯里选**(万元 ⇄ 亿元 这类固定换算),不给自由文本。
+ *
+ * 单位分两层:基准单位(引擎存储/比较用,改不了)与界面单位(这里选的,只影响输入/显示)。
+ * 选成亿元后,策略表单里按亿元输入、提交时 ×10000 存成万元 —— 引擎比较口径不变,
+ * 所以改单位不会让任何历史策略变义。只有一个单位的字段直接显示出来(没什么可选的)。
+ */
+function UnitSelect({
+ field,
+ value,
+ onChange,
+}: {
+ field: ConditionField;
+ value: string;
+ onChange: (unit: string) => void;
+}) {
+ const units = field.units ?? [];
+ const base = field.base_unit || field.unit;
+ if (units.length < 2) {
+ return (
+
+ {value || base}
+
+ );
+ }
+ return (
+
+
+
+ 1 {value} = {factorOf(units, value)} {base}
+
+
+ );
+}
+
+export default function FieldsPage() {
+ const [rows, setRows] = useState(null);
+ const [options, setOptions] = useState([]);
+ const [err, setErr] = useState("");
+ const [msg, setMsg] = useState("");
+ const [busy, setBusy] = useState(false);
+
+ // 行内编辑状态
+ const [editName, setEditName] = useState(null);
+ const [draft, setDraft] = useState(null);
+
+ // 新增表单
+ const [addName, setAddName] = useState("");
+ const [addLabel, setAddLabel] = useState("");
+ const [addDesc, setAddDesc] = useState("");
+ const [addUnit, setAddUnit] = useState("");
+
+ const load = useCallback(async () => {
+ try {
+ const [list, avail] = await Promise.all([
+ apiGet("/condition-fields"),
+ apiGet("/condition-fields/available"),
+ ]);
+ setRows(list);
+ setOptions(avail);
+ } catch (e) {
+ setErr((e as Error).message);
+ }
+ }, []);
+
+ useEffect(() => {
+ void load();
+ }, [load]);
+
+ function startEdit(f: ConditionField) {
+ setErr("");
+ setMsg("");
+ setEditName(f.name);
+ setDraft({
+ label: f.label,
+ description: f.description,
+ unit: f.unit,
+ group_name: f.group_name,
+ enabled: f.enabled,
+ });
+ }
+
+ async function saveEdit() {
+ if (!editName || !draft) return;
+ setBusy(true);
+ setErr("");
+ try {
+ await apiPut(`/condition-fields/${encodeURIComponent(editName)}`, draft);
+ setMsg(`已保存字段「${draft.label}」`);
+ setEditName(null);
+ setDraft(null);
+ await load();
+ } catch (e) {
+ setErr((e as Error).message);
+ } finally {
+ setBusy(false);
+ }
+ }
+
+ /** 停用/启用:内置字段不能删,只能停用(删了下次读取会自动补回)。 */
+ async function toggleEnabled(f: ConditionField) {
+ setBusy(true);
+ setErr("");
+ try {
+ await apiPut(`/condition-fields/${encodeURIComponent(f.name)}`, {
+ enabled: !f.enabled,
+ });
+ setMsg(`字段「${f.label}」已${f.enabled ? "停用(条件编辑器里不再出现)" : "启用"}`);
+ await load();
+ } catch (e) {
+ setErr((e as Error).message);
+ } finally {
+ setBusy(false);
+ }
+ }
+
+ async function remove(f: ConditionField) {
+ if (!window.confirm(`删除自定义字段「${f.label}」(${f.name})?删掉后可重新从「新增字段」加回来。`)) {
+ return;
+ }
+ setBusy(true);
+ setErr("");
+ try {
+ await apiDelete(`/condition-fields/${encodeURIComponent(f.name)}`);
+ setMsg(`已删除自定义字段「${f.label}」`);
+ await load();
+ } catch (e) {
+ setErr((e as Error).message);
+ } finally {
+ setBusy(false);
+ }
+ }
+
+ function pickOption(name: string) {
+ setAddName(name);
+ const o = options.find((x) => x.name === name);
+ if (o) {
+ setAddLabel(o.label);
+ setAddDesc(o.description);
+ setAddUnit(o.unit);
+ }
+ }
+
+ async function addField() {
+ if (!addName) return;
+ setBusy(true);
+ setErr("");
+ setMsg("");
+ try {
+ const created = await apiPost("/condition-fields", {
+ name: addName,
+ label: addLabel,
+ description: addDesc,
+ unit: addUnit,
+ });
+ setMsg(`已新增字段「${created.label}」(${created.name}),现在可以在过滤条件里选它`);
+ setAddName("");
+ setAddLabel("");
+ setAddDesc("");
+ setAddUnit("");
+ await load();
+ } catch (e) {
+ setErr((e as Error).message);
+ } finally {
+ setBusy(false);
+ }
+ }
+
+ // 分组展示:后端已按 sort_order 排序,这里只做「保持首次出现顺序」的分组
+ const groups: { name: string; items: ConditionField[] }[] = [];
+ for (const f of rows ?? []) {
+ const g = groups.find((x) => x.name === f.group_name);
+ if (g) g.items.push(f);
+ else groups.push({ name: f.group_name, items: [f] });
+ }
+ const groupNames = groups.map((g) => g.name);
+
+ return (
+ <>
+
+ 去编辑选股策略
+
+ }
+ />
+
+ {err ? {err} : null}
+ {msg ? {msg} : null}
+
+ {/* 因子 vs 条件的关系(用户问题第 3 条) */}
+
+
+
+ 一句话:条件决定「有没有资格」,因子决定「谁排前面」。
+ 两者在选股流程里是先后两步,不是二选一。
+
+
+ 1 股票池 剔除 ST / 上市天数不足 / 非指数成分
+ 2 过滤条件 全部 AND 通过才有资格(不排序)
+ 3 打分因子 横截面 z-score 加权 → 复合分排序
+ 4 取 TopN N 在回测组合里定,不在策略里
+
+
+ 用你库里的策略举例(高股息 Top20):
+ 条件 dv_ratio ≤ 30 先剔掉「股息率 > 30% 的异常样本」
+ (多为一次性特别分红或股价暴跌,是典型的「高股息陷阱」)—— 这是准入;
+ 因子 dividend_yield 给剩下的股票打分排序,
+ 让股息率更高的排前面 —— 这是优先级。
+ 同一个字段(比如股息率)既可以是条件也可以是因子:当条件用就是「筛掉」,当因子用就是「排序」。
+
+
+ 另外两点容易混:股票池(剔 ST、上市天数、指数成分)也是过滤,但它属于策略的
+ 「universe」,在条件之前执行;取多少只(N)不属于选股策略 ——
+ 同一个策略配不同 N 是不同风险收益,所以它在「回测组合」里填。
+
+
+ 这里只列内置因子名(如 momentum_60)。
+ 自己新建的 参数化因子(如 momentum(window=90,direction=higher_is_better))
+ 在 因子研究页管理,选股策略的条件字段下拉里会一并出现 ——
+ 它们算的是同一个引擎字段,只是参数不同。
+
+
+
+
+ {/* 新增字段 */}
+
+ {options.length === 0 ? (
+ 没有可新增的字段了 —— 引擎支持且未进库的字段都已加入。
+ ) : (
+
+
+
+
+
+ setAddLabel(e.target.value)} />
+
+
+ {(() => {
+ const opt = options.find((o) => o.name === addName);
+ const units = opt?.units ?? [];
+ const base = opt?.unit ?? "";
+ if (units.length < 2) {
+ return {base || "—"}(该字段只有基准单位);
+ }
+ return (
+
+ );
+ })()}
+
+
+ setAddDesc(e.target.value)}
+ placeholder="例:总市值 = 总股本 × 收盘价(万元)"
+ />
+
+
+ 加入字段库
+
+
+ )}
+
+
+ void load()} disabled={busy}>刷新
+ }>
+ {rows === null ? (
+
+ ) : rows.length === 0 ? (
+
+ ) : (
+
+ {groups.map((g) => (
+
+ {g.name} · {g.items.length}
+
+
+ ))}
+
+ 分组顺序与可用比较符由引擎类型决定:{groupNames.length} 个分组。文本字段只能「等于 / 不等于 / 属于 /
+ 不属于」,数值字段才能比大小 —— 这样不会摆出「行业 > 5」这种永远为假的选项。
+ 内置字段不能删除(删掉下次读取会自动补回),不想看到就「停用」。
+
+
+ )}
+
+ >
+ );
+}
\ No newline at end of file
diff --git a/frontend/web/app/globals.css b/frontend/web/app/globals.css
index c854680..b321b1f 100644
--- a/frontend/web/app/globals.css
+++ b/frontend/web/app/globals.css
@@ -708,11 +708,13 @@ input[type="date"].input {
.factor-row {
display: grid;
/* 因子名列封顶 460px:再宽就是「momentum_60」配 1500px 下拉的失调(截图实测)。
- 权重列固定,操作列按内容。整行也限宽,避免在宽屏上拉成一条长线。 */
+ 权重列固定,操作列按内容。整行也限宽,避免在宽屏上拉成一条长线。
+ start 对齐:因子列比别列高(下拉 + 口径/方向/简介两行说明),
+ 居中会让权重框浮在说明旁边显得没对齐。 */
grid-template-columns: minmax(150px, 460px) 118px auto;
- align-items: center;
+ align-items: start;
gap: var(--sp-2);
- max-width: 720px;
+ max-width: 760px;
}
.factor-rows__head {
@@ -731,17 +733,69 @@ input[type="date"].input {
.cond-rows {
display: grid;
gap: var(--sp-2);
- max-width: 760px;
+ max-width: 940px;
}
.cond-rows__head,
.cond-row {
display: grid;
- grid-template-columns: minmax(150px, 360px) 84px minmax(96px, 160px) auto;
- align-items: center;
+ grid-template-columns: minmax(150px, 260px) 84px 110px minmax(120px, 190px) auto;
+ align-items: start;
gap: var(--sp-2);
}
+/* 单元格:下拉 + 该选项的口径说明(因子行与条件行共用 —— 选了就能看到含义,
+ 不用切到「字段库」/「因子研究」页去查) */
+.cell-stack {
+ display: grid;
+ gap: 2px;
+ align-content: center;
+}
+
+.cell-hint {
+ font-size: var(--fs-xs);
+ line-height: 1.35;
+ display: block;
+}
+
+/* 取值单元格:输入框 + 右侧的**界面单位**后缀。存储值一律是基准单位,这里显示的是
+ 字段库选的界面单位(如「亿元」),提交时按系数换算 —— 所以后缀必须始终可见,
+ 否则用户会把「5 亿元」当成「5 万元」来读。 */
+.unit-input {
+ display: grid;
+ grid-template-columns: 1fr auto;
+ align-items: center;
+ gap: 4px;
+}
+
+.unit-suffix {
+ font-size: var(--fs-xs);
+ color: var(--text-3);
+ white-space: nowrap;
+}
+
+/* 因子 vs 条件 的关系说明卡(表单内固定展示) */
+.relation-note {
+ margin-top: 14px;
+ padding: 10px 12px;
+ border: 1px solid var(--line);
+ border-radius: var(--r-2);
+ background: var(--surface-2, transparent);
+ display: grid;
+ gap: 8px;
+}
+
+.relation-note__title {
+ font-weight: 600;
+ font-size: var(--fs-sm);
+}
+
+.relation-note__flow {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 6px;
+}
+
.cond-rows__head {
color: var(--text-3);
font-size: var(--fs-xs);
@@ -2026,3 +2080,108 @@ button.chip:hover {
background: var(--bg-2);
color: var(--text-1);
}
+
+/* ---------- 回测组合:选股策略多选卡片 ---------- */
+.strategy-pick {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
+ gap: var(--sp-2);
+}
+
+.strategy-pick__item {
+ display: grid;
+ grid-template-columns: auto 1fr;
+ grid-template-areas:
+ "check name"
+ "check meta"
+ "desc desc"
+ "factors factors";
+ gap: 2px var(--sp-2);
+ align-items: center;
+ padding: var(--sp-2) var(--sp-3);
+ border: 1px solid var(--line-strong);
+ border-radius: var(--r-sm);
+ background: var(--surface-2);
+ cursor: pointer;
+ transition: border-color 0.15s ease, background 0.15s ease;
+}
+
+.strategy-pick__item:hover {
+ border-color: rgba(150, 165, 195, 0.42);
+}
+
+.strategy-pick__item.is-on {
+ border-color: var(--accent);
+ background: var(--accent-soft);
+}
+
+.strategy-pick__item input[type="checkbox"] {
+ grid-area: check;
+ width: 16px;
+ height: 16px;
+}
+
+.strategy-pick__name {
+ grid-area: name;
+ font-weight: 600;
+ color: var(--text-1);
+ font-size: var(--fs-sm);
+}
+
+.strategy-pick__meta {
+ grid-area: meta;
+ color: var(--text-3);
+ font-size: var(--fs-xs);
+}
+
+.strategy-pick__desc {
+ grid-area: desc;
+ color: var(--text-2);
+ font-size: var(--fs-xs);
+ line-height: 1.5;
+ margin-top: 4px;
+}
+
+.strategy-pick__factors {
+ grid-area: factors;
+ color: var(--violet);
+ font-size: var(--fs-xs);
+ margin-top: 2px;
+}
+
+/* ---------- 回测组合:已保存组合库 ---------- */
+/* 单列纵向列表(每行「摘要 + 操作」),与选股策略卡的网格区分:
+ 组合条目信息更密(参数 chip + 说明 + 三个动作),横排比网格更好扫读。 */
+.combo-list {
+ display: flex;
+ flex-direction: column;
+ gap: var(--sp-2);
+}
+
+.combo-item {
+ display: flex;
+ flex-wrap: wrap;
+ gap: var(--sp-3);
+ align-items: center;
+ justify-content: space-between;
+ padding: var(--sp-2) var(--sp-3);
+ border: 1px solid var(--line-strong);
+ border-radius: var(--r-sm);
+ background: var(--surface-2);
+ transition: border-color 0.15s ease, background 0.15s ease;
+}
+
+.combo-item:hover {
+ border-color: rgba(150, 165, 195, 0.42);
+}
+
+/* 当前载入的组合:与 .strategy-pick__item.is-on 同一视觉语言(强调色描边) */
+.combo-item.is-on {
+ border-color: var(--accent);
+ background: var(--accent-soft);
+}
+
+.combo-item__main {
+ min-width: 0;
+ flex: 1 1 320px;
+}
diff --git a/frontend/web/app/selection/page.tsx b/frontend/web/app/selection/page.tsx
index 72a209b..c98ed38 100644
--- a/frontend/web/app/selection/page.tsx
+++ b/frontend/web/app/selection/page.tsx
@@ -9,6 +9,7 @@ import { useEffect, useState } from "react";
import { apiGet, apiPost } from "@/lib/api";
import { SymbolLink } from "@/lib/symbols";
import { waitJob } from "@/lib/jobs";
+import { factorOptionLabel, pickableFactors } from "@/lib/factors";
import type {
FactorMeta,
SelectionCondition,
@@ -87,7 +88,8 @@ export default function SelectionPage() {
useEffect(() => {
let alive = true;
apiGet("/factors")
- .then((list) => alive && list.length > 0 && setFactors(list))
+ // 停用/算不出来的因子不摆进候选(停用仍可被既有策略解析,只是不该再被选中)
+ .then((list) => alive && list.length > 0 && setFactors(pickableFactors(list)))
.catch((e: Error) => alive && setError(e.message));
apiGet("/selections?limit=8")
.then((rows) => alive && setHistory(rows))
@@ -241,7 +243,7 @@ export default function SelectionPage() {
}}
>
{factors.map((f) => (
-
+
))}
(null);
+ const [draft, setDraft] = useState(DEFAULTS);
+ const [busy, setBusy] = useState(false);
+ const [err, setErr] = useState("");
+ const [msg, setMsg] = useState("");
+
+ useEffect(() => {
+ let alive = true;
+ apiGet("/config")
+ .then((c) => {
+ if (!alive) return;
+ setCfg(c);
+ setDraft(c);
+ })
+ .catch((e: Error) => alive && setErr(e.message));
+ return () => {
+ alive = false;
+ };
+ }, []);
+
+ async function save() {
+ setBusy(true);
+ setErr("");
+ setMsg("");
+ try {
+ const saved = await apiPut("/config", draft);
+ setCfg(saved);
+ setMsg("公共配置已保存(之后的回测组合将采用新值;已归档的结果不受影响)");
+ } catch (e) {
+ setErr((e as Error).message);
+ } finally {
+ setBusy(false);
+ }
+ }
+
+ // 只比较用户可编辑的字段:忽略 id / updated_at(后端回填,draft 里没有),
+ // 否则刚载入就会因 updated_at:null vs undefined 被判为「有改动」而误亮保存按钮。
+ const editable = (c: GlobalConfig) =>
+ JSON.stringify([c.commission_rate, c.stamp_tax_rate, c.slippage_rate, c.min_commission, c.price_adjustment, c.benchmark]);
+ const dirty = cfg ? editable(cfg) !== editable(draft) : false;
+
+ return (
+ <>
+
+
+ {err ? {err} : null}
+ {msg ? {msg} : null}
+
+ {!cfg ? (
+
+ ) : (
+
+ {dirty ? 有未保存的修改 : null}
+
+ 保存配置
+
+
+ }
+ >
+
+
+ setDraft({ ...draft, commission_rate: Number(e.target.value) })}
+ />
+
+
+ setDraft({ ...draft, stamp_tax_rate: Number(e.target.value) })}
+ />
+
+
+ setDraft({ ...draft, slippage_rate: Number(e.target.value) })}
+ />
+
+
+ setDraft({ ...draft, min_commission: Number(e.target.value) })}
+ />
+
+
+
+
+
+ setDraft({ ...draft, benchmark: e.target.value })}
+ />
+
+
+
+
+ 费率以**小数**填写(0.0003 = 万三,不是 0.03%)。这里的值是全局默认;每个回测组合运行时
+ 会把当时的成本/复权**快照**进归档,所以事后改这里不会改变历史结果的数字。
+
+
+ )}
+ >
+ );
+}
diff --git a/frontend/web/app/signals/page.tsx b/frontend/web/app/signals/page.tsx
index 630439d..df5eec7 100644
--- a/frontend/web/app/signals/page.tsx
+++ b/frontend/web/app/signals/page.tsx
@@ -6,6 +6,7 @@
import { useEffect, useState } from "react";
import { apiGet, apiPost } from "@/lib/api";
import { SymbolLink } from "@/lib/symbols";
+import { factorOptionLabel, pickableFactors } from "@/lib/factors";
import type {
FactorMeta,
SelectionQuery,
@@ -55,7 +56,8 @@ export default function SignalsPage() {
useEffect(() => {
let alive = true;
apiGet("/factors")
- .then((list) => alive && list.length > 0 && setFactors(list))
+ // 停用/算不出来的因子不摆进候选(停用仍可被既有策略解析,只是不该再被选中)
+ .then((list) => alive && list.length > 0 && setFactors(pickableFactors(list)))
.catch((e: Error) => alive && setError(e.message));
apiGet("/signals?limit=8").then(setHistory).catch(() => alive && setHistory([]));
return () => {
@@ -116,7 +118,7 @@ export default function SignalsPage() {
diff --git a/frontend/web/app/strategies/page.tsx b/frontend/web/app/strategies/page.tsx
index a034373..ab5de61 100644
--- a/frontend/web/app/strategies/page.tsx
+++ b/frontend/web/app/strategies/page.tsx
@@ -1,23 +1,17 @@
"use client";
/**
- * 策略库(/strategies)—— 策略的命名资产中心。
+ * 选股策略库(/strategies)—— 只管理「选股条件组合」。
*
- * 职责:
- * - 列表 + 检索:每个策略显示**一句话说明**与**计算公式**(后端 describe_strategy 推导)。
- * - 新建 / 编辑:复用 StrategyParamsForm(与回测页同一份参数模型与校验)。
- * - 一键回测:`POST /strategies/{id}/expand`(补 period + 资金)→ `POST /jobs` → 轮询 →
- * 就地展示核心指标,并提供「查看实验详情 / 回到回测页看曲线」。
- * - 去回测页:`/backtest?strategy={id}`(回测页载入后可保存为策略、可微调)。
- *
- * 设计取舍:编辑不弹窗而是就地展开表单(页面滚动位置不丢,参数多,弹窗太挤)。
+ * 2026-09 重构后这里**不再有回测参数**(资金/持仓/调仓/费率/区间都移到回测组合与公共配置)。
+ * 每个策略只回答「怎么选」:股票池 + 因子 + 过滤条件。要验证它,去 /backtest 把它
+ * (可与其他策略一起)放进一个回测组合再跑。
*/
import Link from "next/link";
import { useCallback, useEffect, useMemo, useState } from "react";
import { apiDelete, apiGet, apiPost, apiPut } from "@/lib/api";
-import { STAGE_LABEL, submitJob, waitJob, type JobOutcome } from "@/lib/jobs";
-import { recentRange } from "@/lib/dates";
-import type { BacktestResult, FactorMeta, StrategyDefinition } from "@/lib/types";
+import type { ConditionField, FactorMeta, ResearchCondition, SelectionStrategy } from "@/lib/types";
+import { displayUnitOf, fromBase, unitScale } from "@/lib/units";
import {
Btn,
Banner,
@@ -26,40 +20,67 @@ import {
Loading,
PageHeader,
Pill,
- Progress,
- BacktestMetrics,
- Field,
} from "@/components/ui";
import {
- StrategyParamsForm,
- casePreset,
- emptyParams,
- paramsFromStrategy,
- strategyFromParams,
- validateParams,
- type StrategyParams,
-} from "@/components/StrategyParamsForm";
+ SelectionStrategyForm,
+ emptySelectionParams,
+ paramsFromSelectionStrategy,
+ selectionStrategyFromParams,
+ validateSelectionParams,
+ type SelectionStrategyParams,
+} from "@/components/SelectionStrategyForm";
import { StrategyDocBody, StrategyDocCard } from "@/components/StrategyDocCard";
-import { useStrategyDoc, useStrategyDocById } from "@/lib/strategy";
+import { useStrategyDocById } from "@/lib/strategy";
export default function StrategiesPage() {
- const [list, setList] = useState(null);
+ 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 [editing, setEditing] = useState(null);
const [editingId, setEditingId] = useState(null);
- // 保存被拦下过 → 立即展开全部校验错误(见 StrategyParamsFormProps.revealErrors)
const [revealErrors, setRevealErrors] = useState(false);
const [busy, setBusy] = useState(false);
const [docId, setDocId] = useState(null);
- const range = useMemo(() => recentRange(), []);
-
const load = useCallback(async () => {
try {
- const rows = await apiGet("/strategies");
+ const rows = await apiGet("/strategies");
setList(rows);
setErr("");
} catch (e) {
@@ -74,6 +95,10 @@ export default function StrategiesPage() {
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;
};
@@ -96,19 +121,18 @@ export default function StrategiesPage() {
});
}, [list, query]);
- /* ---------- 新建 / 编辑 ---------- */
function startCreate() {
setEditingId(null);
- setEditing(emptyParams(range));
+ setEditing(emptySelectionParams());
setDocId(null);
setMsg("");
setErr("");
setRevealErrors(false);
}
- function startEdit(s: StrategyDefinition) {
+ function startEdit(s: SelectionStrategy) {
setEditingId(s.id ?? null);
- setEditing(paramsFromStrategy(s, { ...emptyParams(range) }));
+ setEditing(paramsFromSelectionStrategy(s));
setDocId(s.id ?? null);
setMsg("");
setErr("");
@@ -117,22 +141,22 @@ export default function StrategiesPage() {
async function save() {
if (!editing) return;
- const errors = validateParams(editing, { requireMeta: true });
+ const errors = validateSelectionParams(editing);
if (Object.keys(errors).length) {
setErr(Object.values(errors)[0]);
- setRevealErrors(true); // 一次把问题全列出来,而不是让用户逐个试
+ setRevealErrors(true);
return;
}
setBusy(true);
setErr("");
try {
- const body = strategyFromParams(editing, editingId ?? undefined);
+ const body = selectionStrategyFromParams(editing, editingId ?? undefined);
if (editingId) {
- await apiPut(`/strategies/${encodeURIComponent(editingId)}`, body);
- setMsg(`已更新策略「${body.name}」`);
+ await apiPut(`/strategies/${encodeURIComponent(editingId)}`, body);
+ setMsg(`已更新选股策略「${body.name}」`);
} else {
- const saved = await apiPost("/strategies", body);
- setMsg(`已保存策略「${saved.name}」(${saved.id})`);
+ const saved = await apiPost("/strategies", body);
+ setMsg(`已保存选股策略「${saved.name}」(${saved.id})`);
}
setEditing(null);
setEditingId(null);
@@ -145,13 +169,13 @@ export default function StrategiesPage() {
}
}
- async function remove(s: StrategyDefinition) {
+ async function remove(s: SelectionStrategy) {
if (!s.id) return;
- if (!window.confirm(`删除策略「${s.name}」?此操作不可撤销(已跑过的实验不受影响)。`)) return;
+ if (!window.confirm(`删除选股策略「${s.name}」?引用它的回测组合将无法运行。`)) return;
setBusy(true);
try {
await apiDelete(`/strategies/${encodeURIComponent(s.id)}`);
- setMsg(`已删除策略「${s.name}」`);
+ setMsg(`已删除选股策略「${s.name}」`);
await load();
} catch (e) {
setErr((e as Error).message);
@@ -163,15 +187,15 @@ export default function StrategiesPage() {
return (
<>
-
- 查看实验
+
+ 去建回测组合
- 新建策略
+ 新建选股策略
}
@@ -180,11 +204,10 @@ export default function StrategiesPage() {
{err ? {err} : null}
{msg ? {msg} : null}
- {/* ---------- 编辑器 ---------- */}
{editing ? (
{ setEditing(null); setEditingId(null); setDocId(null); }} disabled={busy}>
@@ -196,68 +219,48 @@ export default function StrategiesPage() {
}
>
-
-
- 策略只保存「怎么选股/怎么调仓/怎么收费」,**不含回测区间与初始资金** ——
- 这两项在运行回测时才指定,因此同一策略可用于不同区间的复现与对比。
-
-
-
- setEditing({
- ...casePreset(range),
- name: editing.name,
- description: editing.description,
- })
- }
- >
- 载入高股息案例默认参数
-
+
+ 这里不填资金 / 持仓数 / 持仓时间 / 调仓时机 / 费率 / 复权 / 回测区间 ——
+ 那些是回测时才定的,在「回测组合」里填;费率与复权在「公共配置」里设。
) : null}
- {editing ? (
-
- ) : null}
-
+ {editing ? : null}
{docId && !editing ? setDocId(null)} /> : null}
- {/* ---------- 列表 ---------- */}
setQuery(e.target.value)}
- aria-label="搜索策略"
+ aria-label="搜索选股策略"
/>
}
>
{filtered === null ? (
-
+
) : filtered.length === 0 ? (
) : (
@@ -266,8 +269,9 @@ export default function StrategiesPage() {
startEdit(s)}
onDelete={() => remove(s)}
onDoc={() => setDocId(s.id ?? null)}
@@ -282,17 +286,22 @@ export default function StrategiesPage() {
/* ------------------------------------------------------------------ */
-/** 未保存参数的实时说明预览 */
-function EditingDoc({ params, savedId }: { params: StrategyParams; savedId: string | null }) {
- const live = useStrategyDoc(params, !savedId);
- const saved = useStrategyDocById(savedId);
- const state = savedId ? saved : live;
+function EditingDoc({ id }: { id: string | null }) {
+ // 编辑未保存时无法预览后端说明(需要 id);保存后才能看
+ const state = useStrategyDocById(id);
+ if (!id) {
+ return (
+
+ 保存后即可在此预览后端按选股条件推导的说明与公式。
+
+ );
+ }
return (
);
}
@@ -318,120 +327,64 @@ function SavedDoc({ id, onClose }: { id: string; onClose: () => void }) {
);
}
-/** 单个策略卡片:说明 + 参数摘要 + 一键回测 */
function StrategyCard({
s,
- range,
busy,
+ fieldByName,
+ factorByKey,
onEdit,
onDelete,
onDoc,
}: {
- s: StrategyDefinition;
- range: { start: string; end: string };
+ s: SelectionStrategy;
busy: boolean;
+ fieldByName: Map;
+ factorByKey: Map;
onEdit: () => void;
onDelete: () => void;
onDoc: () => void;
}) {
- const [start, setStart] = useState("2020-01-01");
- const [end, setEnd] = useState(range.end);
- const [capital, setCapital] = useState(1_000_000);
- const [running, setRunning] = useState(false);
- const [jobId, setJobId] = useState("");
- const [result, setResult] = useState(null);
- const [stage, setStage] = useState("");
- const [expId, setExpId] = useState("");
- const [error, setError] = useState("");
- const [open, setOpen] = useState(false);
-
- const sel = s.selection ?? {};
- const costs = s.costs ?? {};
-
- async function run() {
- if (!s.id) return;
- if (start >= end) {
- setError("开始日期必须早于结束日期");
- return;
- }
- setRunning(true);
- setError("");
- setResult(null);
- setExpId("");
- try {
- const spec = await apiPost>(
- `/strategies/${encodeURIComponent(s.id)}/expand`,
- { period: [start, end], initial_capital: capital }
- );
- const { job_id } = await submitJob(spec as never);
- setJobId(job_id);
- setStage("queued");
- const out: JobOutcome = await waitJob(job_id, 900_000, (info) =>
- setStage(info.stage)
- );
- if (out.status === "success" && out.result) {
- setResult(out.result);
- setExpId(out.experimentId ?? "");
- } else {
- setError(`任务${out.status}${out.error ? `:${out.error}` : ""}`);
- }
- } catch (e) {
- setError((e as Error).message);
- } finally {
- setRunning(false);
- setJobId("");
- setStage("");
- }
- }
-
+ const u = s.universe ?? {};
return (
-
- {s.name}
-
+ {s.name}
{s.id}
{s.created_at ? 创建 {String(s.created_at).slice(0, 10)} : null}
- {s.factors?.map((f) => f.name).join(" + ") || "—"}
+ {s.factors?.map((f) => factorLabel(factorByKey.get(f.name), f.name)).join(" + ") || "—"}
- {s.description || "(无说明:建议补一句话说明,便于日后识别)"}
+ {s.description || "(无说明:建议补一句话,便于日后识别)"}
- 候选池 {sel.top_n ?? "—"} → 持仓 {sel.hold_top_x ?? sel.top_n ?? "—"}
-
-
- 择股 {s.selection_interval_months ?? "跟随 y"} 月 / 调仓{" "}
- {s.rebalance_interval_months ?? "跟随 m"} 月
-
-
- 复权 {adjLabel(s.price_adjustment)}
-
-
- {s.universe?.exclude_st ? "剔除 ST" : "含 ST"} · 费率{" "}
- {((costs.commission_rate ?? 0) * 100).toFixed(3)}% + 印花{" "}
- {((costs.stamp_tax_rate ?? 0) * 100).toFixed(2)}%
+ 股票池 {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) => `${c.field} ${opLabel(c.op)} ${c.value ?? ""}`).join(" 且 ")}
+ 条件{" "}
+
+ {(s.conditions ?? [])
+ .map((c) => `${fieldByName.get(c.field)?.label ?? c.field} ${opLabel(c.op)} ${condValueText(c, fieldByName.get(c.field))}`)
+ .join(" 且 ")}
+
- ) : null}
+ ) : (
+ 无过滤条件
+ )}
-
- {running ? "后台运行中…" : "一键回测"}
-
- 载入回测页(可微调)
+ 加入回测组合
看说明/公式
@@ -443,77 +396,19 @@ function StrategyCard({
删除
-
- {/* 运行区间(默认 2020-01-01 ~ 最近交易日) */}
- setOpen((e.target as HTMLDetailsElement).open)}>
-
- 回测区间与资金(一键回测使用)
-
-
-
- setStart(e.target.value)} disabled={running} />
-
-
- setEnd(e.target.value)} disabled={running} />
-
-
- setCapital(Number(e.target.value))}
- disabled={running}
- />
-
-
-
-
- {running ? (
-
);
}
-function adjLabel(m?: string): string {
- return m === "hfq" ? "后复权" : m === "qfq" ? "前复权" : "不复权";
+/**
+ * 卡片上的因子名:优先中文名(含参数,如「动量(窗口 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 {
@@ -521,3 +416,21 @@ function opLabel(op: string): string {
{ 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;
+}
+
diff --git a/frontend/web/components/SelectionStrategyForm.tsx b/frontend/web/components/SelectionStrategyForm.tsx
new file mode 100644
index 0000000..37bab62
--- /dev/null
+++ b/frontend/web/components/SelectionStrategyForm.tsx
@@ -0,0 +1,628 @@
+"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")} }
+
+
+
+
+
+ {/* 股票池 */}
+ 股票池(universe)
+
+
+
+
+
+ set({ minListingDays: Number(e.target.value) })}
+ />
+
+
+ set({ indexCode: e.target.value })}
+ />
+
+
+
+ {/* 因子 */}
+
+ 打分因子(score = Σ 权重 × 因子值,越大越优先)
+
+ set({
+ factors: [
+ ...p.factors,
+ // 默认选中第一个「可用」的因子(停用的不该被默认塞进新策略)
+ {
+ name: (factorOptions.find((o) => o.enabled !== false) ?? factorOptions[0])?.name ?? "",
+ weight: 1,
+ },
+ ],
+ })
+ }
+ >
+ 添加因子
+
+
+
+
+ 因子
+ 权重
+
+
+ {p.factors.map((f, i) => {
+ const meta = factorOptions.find((o) => o.name === f.name);
+ return (
+
+
+
+
+ {meta
+ ? factorHint(meta)
+ : "该名称不在因子目录里:可能是历史策略引用了已删除的因子,运行时会报错"}
+
+
+ setFactor(i, { weight: Number(e.target.value) })}
+ />
+ {p.factors.length > 1 ? (
+ set({ factors: p.factors.filter((_, j) => j !== i) })}
+ />
+ ) : (
+
+ )}
+
+ );
+ })}
+
+ {showErr("factors") && {showErr("factors")} }
+
+ 权重 1 = 等权;多个因子会先各自做横截面 z-score 标准化再加权,避免量纲不同互相压制。
+
+
+ {/* 过滤条件 */}
+
+ 过滤条件(AND,股票池之后、因子排序之前执行)
+
+ 管理字段库
+ set({ conditions: [...p.conditions, { field: "", op: "gte", value: 0 }] })}
+ >
+ 添加条件
+
+
+
+ {p.conditions.length === 0 ? (
+
+ 未设置条件:候选 = 股票池内因子分最高的若干只(具体取多少只在回测组合里定)。
+ 条件用来「筛掉不要的」,因子用来「排序」—— 两者可以同时用。
+
+ ) : (
+
+
+ 字段
+ 比较
+ 取值方式
+ 取值
+
+
+ {p.conditions.map((c, i) => {
+ const meta = fieldByName.get(c.field);
+ const isRef = !!c.ref;
+ const kind = meta?.kind ?? "num";
+ // 单位换算:存储/引擎用基准单位,输入/显示用字段库选的界面单位
+ const scale = kind === "str" ? 1 : unitScale(meta);
+ const baseUnit = baseUnitOf(meta);
+ const displayUnit = displayUnitOf(meta);
+ const setCond = (patch: Partial ) =>
+ set({ conditions: p.conditions.map((x, j) => (j === i ? { ...x, ...patch } : x)) });
+ return (
+
+
+
+
+ {meta ? (
+ <>
+ {meta.description}
+ {scale !== 1 ? (
+ <>
+ {" "}
+
+ 界面单位 {meta.unit}
+
+ :按 {meta.unit} 输入,提交时 ×{scale} 换算成基准单位 {baseUnit}
+ (引擎只按基准单位比较,所以改单位不会让已有策略变义)。
+ >
+ ) : null}
+ >
+ ) : c.field ? (
+ "该字段不在字段库中(可能已停用或删除):请重新选择,否则条件会一直不通过"
+ ) : (
+ "选一个字段 —— 下方会显示它的口径与单位"
+ )}
+
+
+
+
+ {isRef ? (
+
+ ) : (
+
+ setCond({ value: textToValue(e.target.value, c.op, scale, kind) })}
+ />
+ {displayUnit ? (
+
+ {displayUnit}
+
+ ) : null}
+
+ )}
+ set({ conditions: p.conditions.filter((_, j) => j !== i) })}
+ />
+
+ );
+ })}
+
+ )}
+ {showErr("conditions") && {showErr("conditions")} }
+
+ {/* 因子 vs 条件:关系说明(用户 2026-10 反馈第 3 条) */}
+
+ 打分因子 与 过滤条件 的关系
+
+ 1 股票池 剔 ST / 上市天数 / 指数成分
+ 2 过滤条件 全部 AND 通过才有资格
+ 3 打分因子 z-score 加权 → 排序
+ 4 取 TopN N 在回测组合里定
+
+
+ 条件 = 准入(筛掉不要的,不决定顺序);因子 = 优先级(决定谁排前面,
+ 不筛掉任何人)。同一个字段两种用法都行:你的高股息策略里
+ dv_ratio ≤ 30 当条件用,是剔掉股息率异常偏高的
+ 「高股息陷阱」样本;dividend_yield 当因子用,
+ 是让股息率高的排前面。字段的含义与单位见
+ 字段库。
+
+
+
+ );
+}
diff --git a/frontend/web/components/StrategyParamsForm.tsx b/frontend/web/components/StrategyParamsForm.tsx
deleted file mode 100644
index 64c6dd9..0000000
--- a/frontend/web/components/StrategyParamsForm.tsx
+++ /dev/null
@@ -1,820 +0,0 @@
-"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")}
-
- )}
-
-
-
-
- )}
-
- {/* ---------- 因子(打分公式) ----------
- 用「表头 + 栅格行」表达多项因子:列宽由 CSS 栅格统一控制(不再在 JSX 里写
- 内联宽度),这样因子下拉、权重输入、删除按钮在所有行严格成列对齐;
- 视觉表头给正常用户,aria-label 给读屏(重复行只有表头时读屏无法分辨)。 */}
-
- 打分因子(score = Σ 权重 × 因子值,越大越优先)
- {multiFactor && (
- set({ factors: [...p.factors, { name: factorOptions[0]?.name ?? "", weight: 1 }] })}
- >
- 添加因子
-
- )}
-
-
-
- 因子
- 权重
-
-
- {p.factors.map((f, i) => (
-
-
- setFactor(i, { weight: Number(e.target.value) })}
- />
- {multiFactor && p.factors.length > 1 ? (
- /* 与同行的下拉/输入同为 md 高度:行内控件必须等高,否则整行看起来是斜的 */
- set({ factors: p.factors.filter((_, j) => j !== i) })}
- />
- ) : (
-
- )}
-
- ))}
-
-
- 权重 1 = 等权;只想用单个因子时把其它因子删掉即可。多个因子会先各自做横截面
- z-score 标准化再加权,避免量纲不同互相压制。
-
- {errors.factors && (
-
- {errors.factors}
-
- )}
-
- {/* ---------- 选股规模与周期 ---------- */}
-
-
- set({ topN: Number(e.target.value) })}
- onBlur={blur("topN")}
- />
- {showErr("topN") && (
-
- {showErr("topN")}
-
- )}
-
-
- set({ holdX: Number(e.target.value) })}
- onBlur={blur("holdX")}
- />
- {showErr("holdX") && (
-
- {showErr("holdX")}
-
- )}
-
-
- set({ mMonths: Number(e.target.value) })}
- onBlur={blur("mMonths")}
- />
- {showErr("mMonths") && (
-
- {showErr("mMonths")}
-
- )}
-
-
- set({ yMonths: Number(e.target.value) })}
- onBlur={blur("yMonths")}
- />
- {showErr("yMonths") && (
-
- {showErr("yMonths")}
-
- )}
-
-
-
-
-
-
- {(
- [
- ["substitute", "换一只买", "从候选池之外按复合分往下找可买标的,补足持仓数"],
- ["defer", "顺延买入", "等它到之后首个不涨停的交易日再按收盘价买入(到下次调仓仍未成交则作废)"],
- ["none", "不补位", "买不进就空着,实际持仓可能少于持仓数 x"],
- ] as const
- ).map(([val, label, desc]) => (
-
- ))}
-
-
-
-
- {/* ---------- 口径与成本 ---------- */}
-
- {showErr("period") && (
-
- {showErr("period")}
-
- )}
-
- {/* ---------- 选股过滤条件 ---------- */}
-
-
-
- 选股过滤条件(AND,universe 之后、因子排序之前执行)
-
- set({ conditions: [...p.conditions, { field: "", op: "gte", value: 0 }] })}
- >
- 添加条件
-
-
- {p.conditions.length === 0 ? (
- 未设置条件:候选池 = universe 内因子分最高的 n 只。
- ) : (
-
-
- 字段
- 比较
- 取值
-
-
- {p.conditions.map((c, i) => (
-
-
- set({
- conditions: p.conditions.map((x, j) =>
- j === i ? { ...x, field: e.target.value } : x
- ),
- })
- }
- />
-
- {
- const raw = e.target.value;
- const num = Number(raw);
- set({
- conditions: p.conditions.map((x, j) =>
- j === i
- ? { ...x, value: raw !== "" && !Number.isNaN(num) ? num : raw }
- : x
- ),
- });
- }}
- />
- set({ conditions: p.conditions.filter((_, j) => j !== i) })}
- />
-
- ))}
-
- )}
- {errors.conditions && (
-
- {errors.conditions}
-
- )}
-
- 可用字段:每日指标 dv_ratio / dv_ttm / pe / pb / total_mv、行情 close / volume /
- amount、技术 ma20 / ma60、已注册因子名、static.industry 等、fundamental.roe 等
- (财务按公告日 ≤ 择股日取用)。
-
-
-
- );
-}
\ No newline at end of file
diff --git a/frontend/web/components/app-shell.tsx b/frontend/web/components/app-shell.tsx
index 40c90c4..90f5cbd 100644
--- a/frontend/web/components/app-shell.tsx
+++ b/frontend/web/components/app-shell.tsx
@@ -29,9 +29,11 @@ const GROUPS: { title: string; items: NavItem[] }[] = [
{
title: "策略",
items: [
- { href: "/strategies", label: "策略库", icon: "book" },
- { href: "/backtest", label: "选股回测", icon: "gauge" },
+ { href: "/strategies", label: "选股策略库", icon: "book" },
+ { href: "/fields", label: "字段库", icon: "filter" },
+ { href: "/backtest", label: "回测组合", icon: "gauge" },
{ href: "/experiments", label: "实验对比", icon: "archive" },
+ { href: "/settings", label: "公共配置", icon: "database" },
],
},
{
diff --git a/frontend/web/lib/api.ts b/frontend/web/lib/api.ts
index 7ed2656..f15068b 100644
--- a/frontend/web/lib/api.ts
+++ b/frontend/web/lib/api.ts
@@ -67,6 +67,25 @@ export async function apiPut(path: string, body: unknown): Promise {
return (await resp.json()) as T;
}
+/**
+ * PATCH:局部更新(如「启用/停用因子」只改 enabled,不该把整行 PUT 回去)。
+ *
+ * 为什么单列:因子参数化新增了 `PATCH /api/factors {name, enabled}` ——
+ * 名字里有括号/等号/逗号,放路径会被代理折腾,所以放在 body 里用 PATCH。
+ */
+export async function apiPatch(path: string, body: unknown): Promise {
+ const resp = await fetch(`${BASE}${path}`, {
+ method: "PATCH",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(body),
+ });
+ if (!resp.ok) {
+ const text = await resp.text();
+ throw new Error(`PATCH ${path} → ${resp.status}: ${text.slice(0, 300)}`);
+ }
+ return (await resp.json()) as T;
+}
+
export async function apiDelete(path: string): Promise {
const resp = await fetch(`${BASE}${path}`, { method: "DELETE" });
if (!resp.ok) {
diff --git a/frontend/web/lib/factors.ts b/frontend/web/lib/factors.ts
new file mode 100644
index 0000000..7ddc9ba
--- /dev/null
+++ b/frontend/web/lib/factors.ts
@@ -0,0 +1,51 @@
+/**
+ * 因子目录在界面上的共用小工具(参数化之后才需要)。
+ *
+ * 为什么单独一个文件:因子的「名字」在参数化之后变成了带参数的引擎键
+ * (`momentum(window=90,direction=lower_is_better)`)。它**不能**直接当界面文字用 ——
+ * 太长、挤爆布局、也没人想读 `direction=higher_is_better`。而「哪些因子能被选」的规则
+ * (停用的不出现、算不出来的不出现)必须各处一致,否则「停用」在某个页面就成了假开关。
+ */
+
+import type { FactorMeta } from "@/lib/types";
+
+/**
+ * 可以被**选中/引用**的因子。
+ *
+ * - `enabled === false`:在目录里停了用 —— 只是不出现在选择列表里,既有策略/归档仍按名字解析。
+ * - `resolvable === false`:引擎算不出来(历史手工登记行),选了也只会报错,不该摆给人点。
+ *
+ * 注意:这**不是**「能不能解析」的判断(那是引擎的事),只是界面候选集的过滤。
+ */
+export function pickableFactors(list: FactorMeta[]): FactorMeta[] {
+ return list.filter((f) => f.enabled !== false && f.resolvable !== false);
+}
+
+/** 界面显示名:优先后端给的中文名(含参数),没有才退回引擎键。 */
+export function factorLabel(f: FactorMeta | undefined, fallback = ""): string {
+ return f?.label || f?.name || fallback;
+}
+
+/**
+ * 参数化因子的「短键」:只用于展示。
+ *
+ * 参数化因子的名字把参数写全了,直接放进下拉会很长;而 direction 在别处(提示里)已经
+ * 说成「越高越好 / 越低越好」,展示层去掉它不丢信息。**下拉的 value 始终是完整键**
+ * (引擎身份),只有显示文字被缩短。非参数化名(`momentum_60`)原样返回。
+ */
+export function shortFactorKey(name: string): string {
+ const m = /^([A-Za-z_][A-Za-z0-9_]*)\(([^()]*)\)$/.exec(name);
+ if (!m) return name;
+ const kept = m[2]
+ .split(",")
+ .filter((part) => !part.trim().startsWith("direction="))
+ .join(",");
+ return kept ? `${m[1]}(${kept})` : m[1];
+}
+
+/** 下拉/列表里的一行文字:中文名(含参数)· 引擎键。藏着名字就等于藏着身份。 */
+export function factorOptionLabel(f: FactorMeta): string {
+ const d = (f.label || f.description).trim();
+ const head = d.length > 30 ? `${d.slice(0, 30)}…` : d || f.name;
+ return `${head} · ${shortFactorKey(f.name)}`;
+}
\ No newline at end of file
diff --git a/frontend/web/lib/jobs.ts b/frontend/web/lib/jobs.ts
index 6528a2b..9e0a0b0 100644
--- a/frontend/web/lib/jobs.ts
+++ b/frontend/web/lib/jobs.ts
@@ -74,3 +74,19 @@ export const STAGE_LABEL: Record = {
analysis: "汇总指标与曲线",
done: "完成",
};
+
+
+/** 提交一个回测组合为异步 Job(POST /api/combos/run,不保存组合)。 */
+export async function submitComboJob(combo: unknown): Promise {
+ return apiPost("/combos/run", combo);
+}
+
+/**
+ * 运行**已保存**的回测组合(POST /api/combos/{id}/run)。
+ *
+ * 与 submitComboJob 的区别:这里用库里的那份参数,页面上未保存的改动不参与 ——
+ * 「从组合库直接运行」必须跑库里存的那套,否则用户改了一半的表单会污染既有组合的结果。
+ */
+export async function runSavedCombo(comboId: string): Promise {
+ return apiPost(`/combos/${encodeURIComponent(comboId)}/run`, {});
+}
diff --git a/frontend/web/lib/strategy.ts b/frontend/web/lib/strategy.ts
index 5edc597..f0463d7 100644
--- a/frontend/web/lib/strategy.ts
+++ b/frontend/web/lib/strategy.ts
@@ -1,22 +1,18 @@
"use client";
/**
- * 策略说明的获取钩子。
+ * 策略说明的获取钩子(2026-09 重构后简化)。
*
* 说明/公式由后端 `describe_strategy` 从 spec **真实推导**(不是前端拼字符串):
- * 这样「页面显示的公式」与「引擎实际执行的规则」只有一个来源,
- * 不会出现文案与实现漂移(本平台最怕的问题)。
+ * 「页面显示的公式」与「引擎实际执行的规则」只有一个来源,不会文案与实现漂移。
*
- * 两个入口:
- * - `useStrategyDoc(params)`:未保存的参数也能实时预览(POST /strategies/describe),
- * 带去抖,避免每次按键都请求。
- * - `useStrategyDocById(id)`:已保存策略(GET /strategies/{id}/describe)。
+ * 重构后选股策略只含选股条件,说明走 `GET /strategies/{id}/describe`(需要已保存的 id);
+ * 未保存参数的实时预览不再有意义(选股策略没有可即时预览的回测公式),故移除。
*/
-import { useEffect, useMemo, useRef, useState } from "react";
+import { useEffect, useState } from "react";
-import { apiGet, apiPost } from "@/lib/api";
+import { apiGet } from "@/lib/api";
import type { StrategyDoc } from "@/lib/types";
-import { paramsToSpec, type StrategyParams } from "@/components/StrategyParamsForm";
export interface DocState {
doc: StrategyDoc | null;
@@ -26,54 +22,7 @@ export interface DocState {
const EMPTY: DocState = { doc: null, loading: false, error: "" };
-/** 未保存参数 → 说明(去抖 500ms) */
-export function useStrategyDoc(params: StrategyParams | null, enabled = true): DocState {
- const [state, setState] = useState(EMPTY);
- const timer = useRef | null>(null);
- // 只依赖会改变说明的字段,避免改「初始资金」也重新请求
- const key = useMemo(() => {
- if (!params) return "";
- return JSON.stringify({
- f: params.factors,
- a: params.priceAdjustment,
- n: params.topN,
- x: params.holdX,
- m: params.mMonths,
- y: params.yMonths,
- r: params.rebalance,
- fp: params.fillPolicy,
- c: params.conditions,
- s: params.excludeSt,
- l: params.minListingDays,
- cost: [params.commission, params.stamp, params.slippage, params.minCommission],
- });
- }, [params]);
-
- useEffect(() => {
- if (!params || !enabled || !key) {
- setState(EMPTY);
- return;
- }
- let alive = true;
- if (timer.current) clearTimeout(timer.current);
- setState((s) => ({ ...s, loading: true }));
- timer.current = setTimeout(() => {
- apiPost("/strategies/describe", paramsToSpec(params))
- .then((doc) => alive && setState({ doc, loading: false, error: "" }))
- .catch((e: Error) => alive && setState({ doc: null, loading: false, error: e.message }));
- }, 500);
- return () => {
- alive = false;
- if (timer.current) clearTimeout(timer.current);
- };
- // key 已覆盖所有影响说明的字段
- // eslint-disable-next-line react-hooks/exhaustive-deps
- }, [key, enabled]);
-
- return state;
-}
-
-/** 已保存策略 → 说明 */
+/** 已保存选股策略 → 说明(后端按选股条件推导) */
export function useStrategyDocById(id: string | null): DocState {
const [state, setState] = useState(EMPTY);
useEffect(() => {
@@ -91,4 +40,4 @@ export function useStrategyDocById(id: string | null): DocState {
};
}, [id]);
return state;
-}
\ No newline at end of file
+}
diff --git a/frontend/web/lib/types.ts b/frontend/web/lib/types.ts
index 5ceebe9..75e7382 100644
--- a/frontend/web/lib/types.ts
+++ b/frontend/web/lib/types.ts
@@ -12,6 +12,26 @@ export interface Stock {
status: string;
}
+/** 因子的一个**可编辑参数**的约束(/api/factors 的 param_specs;与后端 ParamSpec 对齐)。 */
+export interface FactorParam {
+ name: string;
+ label: string;
+ /** "int":整数,按 minimum/maximum 受控;"enum":只能取 choices 之一。 */
+ kind: "int" | "enum";
+ default: number | string;
+ minimum?: number | null;
+ maximum?: number | null;
+ choices?: string[];
+ note?: string;
+}
+
+/**
+ * 因子目录条目(/api/factors)。
+ *
+ * 参数化的关键:因子实例的名字里带着全部参数
+ * (如 `momentum(window=90,direction=higher_is_better)`),所以**名字就是身份** ——
+ * 策略/归档存下名字就冻结了参数,改参数只会产生新名字,历史不会变义。
+ */
export interface FactorMeta {
name: string;
description: string;
@@ -20,6 +40,38 @@ export interface FactorMeta {
frequency: string;
lookback: number;
direction: "higher_is_better" | "lower_is_better";
+ /** 该因子消费的数据列(引擎口径):策略表单据此提示「需要 dv_ratio」这类依赖。 */
+ requires?: string[];
+ /** 中文显示名(含参数),如「动量(窗口 90,越高越好)」;界面优先用它。 */
+ label?: string;
+ /** 模板名("momentum" / "volatility"…);老式手登记因子为空串。 */
+ template?: string;
+ /** 该实例冻结的参数取值,如 `{window: 90, direction: "higher_is_better"}`。 */
+ params?: Record;
+ /** 可编辑参数与允许范围(来自模板):界面据此渲染受控表单。 */
+ param_specs?: FactorParam[];
+ /** builtin = 代码注册表实例;custom = 目录里创建的参数化实例。 */
+ source?: "builtin" | "custom";
+ /** 是否出现在因子的选择列表里(停用只影响「能否被选中」)。 */
+ enabled?: boolean;
+ /** 引擎是否算得出来;false = 历史手登记行,引用时会报错。 */
+ resolvable?: boolean;
+}
+
+/** 因子模板(/api/factors/templates):新建参数化因子时的可编辑参数与默认值。 */
+export interface FactorTemplate {
+ name: string;
+ label: string;
+ description: string;
+ formula: string;
+ brief: string;
+ requires: string[];
+ frequency: string;
+ direction_default: "higher_is_better" | "lower_is_better";
+ param_specs: FactorParam[];
+ defaults: Record;
+ /** 该模板已有的内置实例名(如 momentum_60),供「目录里已有哪些」提示。 */
+ instances: string[];
}
export interface ResearchCondition {
@@ -29,6 +81,47 @@ export interface ResearchCondition {
ref?: string | null;
}
+/**
+ * 字段库条目(/api/condition-fields)。name 是引擎字段名(写进 condition.field),
+ * label/description 是给人看的;ops 由后端按 kind 给出,避免前端自己猜比较符。
+ */
+/** 一个可选的**界面单位**及它到**基准单位**的换算系数(提交前 ×factor,回显时 ÷factor)。 */
+export interface UnitOption {
+ unit: string;
+ factor: number;
+}
+
+export interface ConditionField {
+ name: string;
+ label: string;
+ description: string;
+ kind: "num" | "str";
+ group_name: string;
+ /** 当前**界面单位**(输入/显示用,可从 units 里选)。 */
+ unit: string;
+ /** **基准单位**:引擎存储与比较用的单位,不可改(注册表口径)。 */
+ base_unit?: string;
+ /** 可选界面单位(首项 = 基准单位、factor=1;只有一个时界面不给选择)。 */
+ units?: UnitOption[];
+ source: "builtin" | "custom";
+ enabled: boolean;
+ sort_order: number;
+ ops: ("gt" | "gte" | "lt" | "lte" | "eq" | "ne" | "in" | "not_in")[];
+ created_at?: string;
+ updated_at?: string;
+}
+
+/** 「新增字段」的可选项(引擎支持但尚未进库)。 */
+export interface ConditionFieldOption {
+ name: string;
+ label: string;
+ description: string;
+ kind: "num" | "str";
+ group_name: string;
+ unit: string;
+ units?: UnitOption[];
+}
+
export interface ResearchSpec {
type: "factor_test" | "backtest";
universe: {
@@ -305,39 +398,60 @@ export interface ChartResult {
}
-/* ---- 策略库(M8.3,与 domain/entities/strategy.py 对应) ---- */
+/* ---- 选股策略 / 公共配置 / 回测组合(2026-09 重构,与 domain/entities/{strategy,combo}.py 对应) ---- */
/**
- * 命名策略:完整策略定义(universe + factor + selection + rebalance + costs + portfolio),
- * 不含回测区间 period 与初始资金 —— 回测时补全后展开为 ResearchSpec。
- * 后端以 JSON 整体持久化,因此新增字段无需迁移即可保存。
+ * 选股策略:策略库现在**只存选股条件组合**(股票池 + 因子 + 过滤条件)。
+ * 资金 / 持仓数 / 持仓时间 / 调仓时机 / 费率 / 复权 / 区间一律移到「回测组合」与「公共配置」。
+ * (旧名 StrategyDefinition 仍作为别名导出,便于过渡期引用。)
*/
-export interface StrategyDefinition {
+export interface SelectionStrategy {
id?: string;
name: string;
- /** 一句话说明(必填):说清这个策略做什么。为空时后端会用 describe_strategy 自动填充 */
+ /** 一句话说明(必填):说清怎么选。为空时后端用 describe_strategy 自动填充 */
description: string;
- spec_type?: "backtest" | "factor_test";
- universe?: { exclude_st?: boolean; min_listing_days?: number; symbols?: string[] };
- price_adjustment?: "none" | "qfq" | "hfq";
+ spec_type?: "selection" | "backtest";
+ universe?: {
+ market?: string;
+ exclude_st?: boolean;
+ exclude_suspended?: boolean;
+ min_listing_days?: number;
+ index_code?: string | null;
+ symbols?: string[];
+ };
factors: { name: string; weight: number }[];
- selection?: {
- top_n?: number;
- hold_top_x?: number | null;
- allow_substitute?: boolean;
- defer_buy?: boolean;
- };
- rebalance?: "weekly" | "monthly";
conditions?: ResearchCondition[];
- selection_interval_months?: number | null;
- rebalance_interval_months?: number | null;
- costs?: {
- commission_rate?: number;
- stamp_tax_rate?: number;
- slippage_rate?: number;
- min_commission?: number;
- };
- portfolio?: Record;
+ version?: string;
+ created_at?: string | null;
+}
+
+/** 兼容别名:重构前的名字。新代码请用 SelectionStrategy。 */
+export type StrategyDefinition = SelectionStrategy;
+
+/** 公共配置(全局唯一一份):费率 / 滑点 / 最低佣金 / 复权口径 / 基准。 */
+export interface GlobalConfig {
+ id?: string;
+ commission_rate: number; // 小数,如 0.0003 = 万三
+ stamp_tax_rate: number;
+ slippage_rate: number;
+ min_commission: number; // 元/笔
+ price_adjustment: "none" | "qfq" | "hfq";
+ benchmark: string;
+ updated_at?: string | null;
+}
+
+/** 回测组合:引用若干选股策略 + 回测参数(费率/复权来自公共配置,运行时快照进归档)。 */
+export interface BacktestCombo {
+ id?: string;
+ name: string;
+ description?: string;
+ strategy_ids: string[];
+ initial_capital: number;
+ hold_count: number; // 目标持仓只数 N
+ hold_min_days: number; // Tmin
+ hold_max_days: number | null; // Tmax;null = 不强制了结
+ rebalance_freq: "daily" | "weekly" | "monthly";
+ period: [string, string];
version?: string;
created_at?: string | null;
}
diff --git a/frontend/web/lib/units.ts b/frontend/web/lib/units.ts
new file mode 100644
index 0000000..05896e2
--- /dev/null
+++ b/frontend/web/lib/units.ts
@@ -0,0 +1,53 @@
+/**
+ * 单位换算(界面层)——「字段库单位」这件事唯一的一处实现。
+ *
+ * 背景(2026-10):字段库里的单位分两层,写错任何一层都会让策略静默算错:
+ *
+ * - **基准单位**(`ConditionField.base_unit`):引擎存储与比较用的单位,由数据源落库口径
+ * 决定(总市值=万元、成交额=元、成交量=股),**不可改**。策略 JSON、归档里的
+ * ConditionSpec、引擎求值全部只用基准单位 —— 所以归档永远复现得出来。
+ * - **界面单位**(`ConditionField.unit`):只在输入/显示这一层用的单位,用户可以在字段库里
+ * 从 `units` 给定的阶梯里选(万元 ⇄ 亿元)。提交前 ×factor,回显时 ÷factor。
+ *
+ * 这样「把总市值改成亿元」会立刻生效(输入 5 就是 5 亿元),但不会改动任何已存策略的
+ * 含义 —— 库里存的仍是 50000 万元。
+ */
+import type { ConditionField, UnitOption } from "@/lib/types";
+
+/** 该字段当前界面单位 → 基准单位的换算系数(没有备选单位 → 1)。 */
+export function unitScale(meta: ConditionField | undefined | null): number {
+ if (!meta?.units?.length || meta.units.length < 2) return 1;
+ return meta.units.find((u) => u.unit === meta.unit)?.factor ?? 1;
+}
+
+/** 基准单位值 → 界面单位值(除以系数)。 */
+export function fromBase(v: number, scale: number): number {
+ return !scale || scale === 1 ? v : Number((v / scale).toPrecision(12));
+}
+
+/** 界面单位值 → 基准单位值(乘以系数)。 */
+export function toBase(v: number, scale: number): number {
+ return !scale || scale === 1 ? v : Number((v * scale).toPrecision(12));
+}
+
+/** 基准单位(引擎口径);字段不在库里时为空。 */
+export function baseUnitOf(meta: ConditionField | undefined | null): string {
+ return meta?.base_unit || meta?.unit || "";
+}
+
+/** 界面上该显示的单位后缀:只有真的能换算(有备选且当前不是基准)时才显示。 */
+export function displayUnitOf(meta: ConditionField | undefined | null): string {
+ return unitScale(meta) === 1 ? "" : (meta?.unit ?? "");
+}
+
+/** 单位阶梯的说明文字,如「1 亿元 = 10000 万元」。 */
+export function unitNote(meta: ConditionField | undefined | null): string {
+ const scale = unitScale(meta);
+ if (scale === 1) return "";
+ return `1 ${meta?.unit} = ${scale} ${baseUnitOf(meta)}(引擎按基准单位比较)`;
+}
+
+/** 某个单位在阶梯里的系数(找不到 → 1)。 */
+export function factorOf(units: UnitOption[] | undefined, unit: string): number {
+ return units?.find((u) => u.unit === unit)?.factor ?? 1;
+}
\ No newline at end of file
|