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)。
This commit is contained in:
Simon
2026-10-01 16:38:29 +08:00
parent 2e90f3eeac
commit f13b34c59e
19 changed files with 2982 additions and 1521 deletions
+18 -8
View File
@@ -3,6 +3,7 @@
import { useEffect, useState } from "react";
import { apiGet } from "@/lib/api";
import { submitJob, waitJob } from "@/lib/jobs";
import { factorLabel, pickableFactors } from "@/lib/factors";
import type { BacktestResult, FactorMeta, ResearchSpec } from "@/lib/types";
import { recentRange } from "@/lib/dates";
import {
@@ -45,7 +46,8 @@ export default function ComposePage() {
apiGet<FactorMeta[]>("/factors")
.then((list) => {
if (!alive) return;
setFactors(list);
// 停用/算不出来的因子不摆进候选(停用仍可被既有策略解析,只是不该再被选中)
setFactors(pickableFactors(list));
setPicks([
{ name: "momentum_60", weight: 1.5 },
{ name: "volatility_60", weight: 1.0 },
@@ -159,15 +161,23 @@ export default function ComposePage() {
return (
<tr key={f.name} className={pick ? "row-active" : undefined}>
<td>
<input
type="checkbox"
checked={!!pick}
onChange={() => togglePick(f.name)}
aria-label={`选择因子 ${f.name}`}
/>
{/* 勾选框要用 .check--cell 包一层:裸 16px 的 checkbox 点击目标太小
(本项目 UI 自检要求 ≥28px,且与同排输入框等高) */}
<label className="check check--cell">
<input
type="checkbox"
checked={!!pick}
onChange={() => togglePick(f.name)}
aria-label={`选择因子 ${f.name}`}
/>
</label>
</td>
<td className="cell-strong">
<span className="mono">{f.name}</span>
{factorLabel(f)}
{/* 名字即身份:中文名旁边的引擎键必须看得见(含参数,如 momentum(window=90,…)) */}
<span className="hint mono cell-hint" style={{ fontWeight: 400, overflowWrap: "anywhere" }}>
{f.name}
</span>
</td>
<td>
<Pill tone={f.direction === "higher_is_better" ? "accent" : "violet"}>
+532 -20
View File
@@ -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<string, string> = {
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<string, number | string> {
const out: Record<string, number | string> = {};
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, number | string>): 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, number | string>): 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, number | string>): 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<FactorMeta[]>([]);
const [templates, setTemplates] = useState<FactorTemplate[]>([]);
const [loading, setLoading] = useState(true);
const [checked, setChecked] = useState<Set<string>>(new Set());
const [expanded, setExpanded] = useState<Set<string>>(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<Record<string, number | string>>({});
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<FactorMeta[]>("/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<FactorTemplate[]>("/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<FactorMeta>("/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<FactorMeta>("/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 (
<>
<PageHeader
@@ -132,6 +377,7 @@ export default function FactorsPage() {
<pre style={{ margin: 0, whiteSpace: "pre-line", font: "inherit" }}>{error}</pre>
</Banner>
) : null}
{notice && !running ? <Banner tone="info">{notice}</Banner> : null}
<Card
title="因子目录"
@@ -140,6 +386,18 @@ export default function FactorsPage() {
tools={<Pill tone="violet">已选 {selectedCount} 个</Pill>}
flush
>
{/* 目录的性质说明:放在 loading 分支之外,加载中也能看到(也是 SSR 可断言的静态文案)。
参数化之后这里必须说清新分工:口径文案仍以代码为准,参数则靠「新建参数化因子」来改。
overflowWrap:示例键里没有空格,窄屏必须能断行,否则整页会横向溢出。 */}
<div className="hint" style={{ padding: "10px 14px 0", overflowWrap: "anywhere" }}>
目录是代码注册表(<span className="mono">quant/factors.py</span>)的<b>投影</b>:
口径文案(描述 / 公式 / 方向 / 回看)由引擎决定并自动同步(手改会在下次读取时被纠正回代码文本)。
<b>参数(窗口、方向)可以改</b>,改的方式是「从模板新建一个参数化因子」——
参数写进因子的名字里(如 <span className="mono">momentum(window=90,direction=higher_is_better)</span>),
所以新因子是一个<b>新身份</b>:旧因子、既有策略与归档都按各自名字里的参数计算,
<b>不会变义</b>。参数只在模板给定的受控范围内可选 / 可填,<b>越界会被后端拒绝</b>(不会静默截断成边界值)。
依赖列(<span className="mono">requires</span>)仍不可改 —— 它是「引擎能不能算」的事实,不是配置。
</div>
{loading ? (
<div style={{ padding: 18 }}>
<SkeletonLines n={7} />
@@ -158,9 +416,11 @@ export default function FactorsPage() {
<tr>
<th style={{ width: 44 }}></th>
<th>因子</th>
<th>参数</th>
<th>回看</th>
<th>方向</th>
<th>简介(用法 / 何时有效)</th>
<th style={{ width: 96 }}>操作</th>
</tr>
</thead>
<tbody>
@@ -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)}
/>
))}
</tbody>
@@ -184,6 +446,139 @@ export default function FactorsPage() {
)}
</Card>
<Card
icon="plus"
title="新建参数化因子"
sub="参数写进名字里 —— 新因子是一个新身份,旧因子与既有策略不变义"
tools={creating ? <Pill tone="accent" icon="spinner">创建中</Pill> : undefined}
>
{tplLoading ? (
<SkeletonLines n={3} />
) : templates.length === 0 ? (
<div className="stack" style={{ gap: 10 }}>
<div className="hint">
模板清单没加载出来(<span className="mono">GET /api/factors/templates</span> 未返回模板),
所以暂时无法新建参数化因子;目录本身不受影响。
</div>
{createErr ? <Banner tone="error">{createErr}</Banner> : null}
</div>
) : (
<div className="stack" style={{ gap: 12 }}>
<div className="form-grid">
<Field
label="模板"
className="field--wide"
htmlFor="factor-new-template"
hint="只能从代码注册的模板派生:引擎算不出来的参数组合在这里根本不会出现"
>
<select
id="factor-new-template"
className="input"
value={tplName}
onChange={(e) => pickTemplate(e.target.value)}
>
<option value="">选择模板…</option>
{templates.map((t) => (
<option key={t.name} value={t.name}>
{t.label}({t.name}
{t.instances.length > 0
? `,已有 ${t.instances.join(" / ")}`
: ",还没有实例"}
)
</option>
))}
</select>
</Field>
{/* 受控表单:参数从模板 param_specs 来,顺序也照它(方向恒在最后) */}
{tplSpecs.map((s) =>
s.kind === "int" ? (
<Field
key={s.name}
label={`${s.label}(${s.name})`}
htmlFor={`factor-new-${s.name}`}
hint={`${paramRangeText(s)};越界会被后端拒绝,不会截断成边界值`}
>
<input
id={`factor-new-${s.name}`}
className="input"
type="number"
min={s.minimum ?? undefined}
max={s.maximum ?? undefined}
step={1}
value={String(paramValues[s.name] ?? "")}
onChange={(e) => setParam(s.name, e.target.value)}
/>
</Field>
) : (
<Field
key={s.name}
label={`${s.label}(${s.name})`}
htmlFor={`factor-new-${s.name}`}
hint={paramRangeText(s)}
>
<select
id={`factor-new-${s.name}`}
className="input"
value={String(paramValues[s.name] ?? s.default)}
onChange={(e) => setParam(s.name, e.target.value)}
>
{(s.choices ?? []).map((c) => (
<option key={c} value={c}>
{DIRECTION_TEXT[c] ?? c}
</option>
))}
</select>
</Field>
),
)}
<Btn
variant="primary"
icon="plus"
loading={creating}
disabled={creating || !tpl}
onClick={createFactor}
>
新建参数化因子
</Btn>
</div>
{tpl ? (
/* overflowWrap:预览键是长且无空格的引擎键,窄屏要能断行 */
<div className="stack" style={{ gap: 4, overflowWrap: "anywhere" }}>
<div className="field__label">创建预览</div>
<div className="hint">
因子键:<b className="mono">{previewKey(tpl, paramValues)}</b>
{" "}(参数顺序与模板一致,方向恒在最后;这个键就是身份)
</div>
<div className="hint">
中文名:{previewLabel(tpl, paramValues)}
{" "}· 公式 <span className="mono">{renderFormula(tpl, paramValues)}</span>
</div>
<div className="hint">
中文名按引擎的通用规则预览(量比这类有定制命名的模板,创建后以引擎返回的名字为准)。
</div>
</div>
) : null}
{created ? (
<Banner tone="info">
已创建因子 <b className="mono">{created.name}</b>
{created.label ? <>({created.label})</> : null}:参数已经写进名字里,
这是一个新身份 —— <b>旧因子与既有策略不变义</b>。
</Banner>
) : null}
{createErr ? <Banner tone="error">{createErr}</Banner> : null}
<div className="hint">
参数相同不会重复创建:同一模板、同一组参数只对应一个因子键,重复提交会收到后端的重名提示。
要换参数就再建一个(新键),不要指望改旧键 —— 旧键被改了,引用它的策略与归档就会变义。
</div>
</div>
)}
</Card>
<Card title="运行单因子测试" icon="play" tools={running ? <Pill tone="accent" icon="spinner">执行中</Pill> : undefined}>
<div className="form-grid">
<Field label="开始日期">
@@ -238,8 +633,16 @@ export default function FactorsPage() {
<Card
key={name}
icon="chartLine"
title={name}
sub={meta?.brief}
title={meta ? factorLabel(meta) : name}
sub={
meta && meta.label && meta.label !== name ? (
<span style={{ overflowWrap: "anywhere" }}>
<span className="mono">{name}</span> · {meta.brief}
</span>
) : (
meta?.brief
)
}
tools={
<Pill tone="pos" icon="check">
已完成
@@ -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 (
<>
<tr style={{ cursor: "pointer" }} onClick={props.onToggleExpand}>
<tr
style={{ cursor: "pointer", opacity: f.enabled === false ? 0.55 : 1 }}
onClick={props.onToggleExpand}
>
<td onClick={(e) => e.stopPropagation()}>
<label className="check check--cell" title={`选择因子 ${f.name}`}>
<input
@@ -276,29 +689,128 @@ function FactorRow(props: {
</label>
</td>
<td className="cell-strong">
<span className="mono">{f.name}</span>
{label}
{/* 名字就是身份:中文名旁边的引擎键必须看得见(含参数,如 momentum(window=90,…)) */}
{f.label && f.label !== f.name ? (
<span
className="hint mono cell-hint"
style={{ fontWeight: 400, overflowWrap: "anywhere" }}
>
{f.name}
</span>
) : null}
{f.enabled === false ? <> <Pill tone="warn">已停用</Pill></> : null}
{f.resolvable === false ? <> <Pill tone="neg">引擎算不出来</Pill></> : null}
</td>
<td className="hint">{paramSummary(f)}</td>
<td>
<span className="tag tag--mono">{f.lookback} 日</span>
</td>
<td>
<Pill tone={f.direction === "higher_is_better" ? "accent" : "violet"}>
{f.direction === "higher_is_better" ? "高为好" : "低为好"}
</Pill>
{/* 算不出来的历史行没有可信方向(direction 只是 DB 默认值),不摆一个假的方向 */}
{f.resolvable === false ? (
<span className="hint">—</span>
) : (
<Pill tone={high ? "accent" : "violet"}>{high ? "越高越好" : "越低越好"}</Pill>
)}
</td>
<td className="hint">{f.brief ?? f.description}</td>
<td onClick={(e) => e.stopPropagation()}>
{canToggle ? (
<Btn
disabled={props.toggling}
loading={props.toggling}
onClick={props.onToggleEnabled}
title={f.enabled === false ? "启用后重新出现在选择列表里" : "停用只是不出现在选择列表里,历史解析不变"}
>
{f.enabled === false ? "启用" : "停用"}
</Btn>
) : (
<Btn
disabled
title={
f.resolvable === false
? "引擎算不出来(历史手工登记行),不能启用或停用"
: "内置实例的开关由代码决定,不能在目录里改;要不同参数请从模板新建参数化因子"
}
>
停用
</Btn>
)}
</td>
</tr>
{props.expanded ? (
<tr className="expand-cell">
<td colSpan={5}>
<div>
<b className="mono">{f.name}</b> · {f.description}
<div className="hint" style={{ marginTop: 6 }}>
公式:<span className="mono">{f.formula}</span>;回看 {f.lookback} 个交易日;频率 {f.frequency};
方向:
{f.direction === "higher_is_better" ? "因子值越高得分越高" : "因子值越低得分越高(引擎自动反向)"}
<td colSpan={7}>
<div className="stack" style={{ gap: 8 }}>
{/* 展开行里也会出现长参数键,窄屏同样要能断行 */}
<div style={{ overflowWrap: "anywhere" }}>
<b className="mono">{f.name}</b> · {f.description}
</div>
{f.brief ? <div className="hint" style={{ marginTop: 6 }}>{f.brief}</div> : null}
<div className="stack" style={{ gap: 3 }}>
<div className="field__label">参数(写进名字里的身份)</div>
{(f.param_specs ?? []).length > 0 ? (
(f.param_specs ?? []).map((s) => (
<div key={s.name} className="row" style={{ gap: 8, flexWrap: "wrap" }}>
<span className="mono" style={{ minWidth: 72 }}>{s.name}</span>
<b>{paramValueText(s, (f.params ?? {})[s.name])}</b>
{/* 枚举参数的 note 往往已把可选值抄了一遍(如方向),就不再重复一次 */}
<span className="hint">
{s.kind === "int"
? `允许 ${paramRangeText(s)}${s.note ? ` · ${s.note}` : ""}`
: `可选 ${paramRangeText(s)}${
s.note && !s.note.includes(paramRangeText(s)) ? ` · ${s.note}` : ""
}`}
</span>
</div>
))
) : (
<div className="hint">
该因子没有声明可编辑参数{f.resolvable === false ? "(历史手工登记行)" : "(时点值口径,无窗口)"}。
</div>
)}
</div>
<div className="hint">
公式:<span className="mono">{f.formula}</span>;频率 {f.frequency};回看 {f.lookback} 个交易日;
方向:
{f.resolvable === false
? "未知(引擎算不出来)"
: high
? "因子值越高得分越高"
: "因子值越低得分越高(引擎自动反向)"}
</div>
<div className="hint">
依赖列:
{f.requires && f.requires.length > 0 ? (
<span className="mono">{f.requires.join(" / ")}</span>
) : (
"无"
)}
(引擎能不能算的事实,不可改)
</div>
<div className="row" style={{ gap: 8, flexWrap: "wrap" }}>
<Pill>{sourceText(f)}</Pill>
{f.template ? (
<span className="hint">
模板 <span className="mono">{f.template}</span>
</span>
) : null}
<Pill tone={f.enabled === false ? "warn" : "pos"}>
{f.enabled === false ? "已停用" : "启用中"}
</Pill>
{f.enabled === false ? (
<span className="hint">既有策略 / 归档仍按它的名字解析,停用只是不出现在选择列表里。</span>
) : null}
</div>
{f.resolvable === false ? (
<Banner tone="warn">
引擎算不出来(历史手工登记行),引用时会报错:这不是「配置不一致」,
而是代码注册表里没有能解析它的模板 / 参数。
</Banner>
) : null}
{f.brief ? <div className="hint">{f.brief}</div> : null}
</div>
</td>
</tr>
@@ -365,4 +877,4 @@ function ReportView({ report }: { report: FactorTestReport }) {
</div>
</div>
);
}
}