Files
qlib/frontend/web/app/factors/page.tsx
T
Simon f13b34c59e feat(web): 字段库页 + 界面单位换算 + 因子目录参数化 + 选股策略条件表单
字段库与单位:
- `/fields`:字段库管理页(中文名/说明可改、可停用;kind 是引擎事实不可改);
  单位只在字段自己的阶梯里选(总市值 = 万元/亿元),越界 422 原样展示。
- `lib/units.ts`:界面单位 ⇄ 基准单位换算集中一处,条件输入按界面单位回显、
  提交前换回基准单位(引擎只认基准单位,库里存的也永远是基准单位)。
- `SelectionStrategyForm` 取代 `StrategyParamsForm`:一个策略只定义「怎么选」
  (股票池 + 因子 + 过滤条件),条件字段来自字段库接口而不是前端硬编码枚举。

因子参数化(名字即身份,界面不许藏):
- `/factors` 新增「参数」列与展开行:精确引擎键、每个参数的允许范围、来源、依赖列
  (依赖列标明「引擎事实,不可改」);说明文案从「不可修改」改为
  「改参数 = 新建参数化因子 = 新身份,旧因子/既有策略不变义」。
- 「新建参数化因子」卡片:模板下拉 + 受控窗口(min/max)+ 方向枚举下拉,
  实时预览规范键/中文名/渲染公式;后端 422 的原文原样展示,不静默截断。
- 因子下拉显示中文名(含参数)与短键,**value 一律是完整引擎键**;
  停用的因子从各页候选里消失(/strategies、/selection、/signals、/factors/compose),
  既有策略/归档仍按名字解析;`/factors/compose` 顺手修了勾选框点击目标过小。
- `/fields` 加提示:字段库只列内置因子名,参数化因子在 /factors 管理,
  选股策略条件下拉里会一并出现。

tsc --noEmit 0 error;verify_ui_alignment 8 页 160 项全过(另跑 3 个选因子页 51/54)。
2026-10-01 16:38:29 +08:00

880 lines
35 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { useCallback, useEffect, useState } from "react";
import { apiGet, apiPatch, apiPost } from "@/lib/api";
import { submitJob, waitJob } from "@/lib/jobs";
import type {
FactorMeta,
FactorParam,
FactorTemplate,
FactorTestReport,
ResearchSpec,
} from "@/lib/types";
import { recentRange } from "@/lib/dates";
import {
PageHeader,
Card,
Metric,
Pill,
Field,
Btn,
Banner,
Progress,
Empty,
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());
const [start, setStart] = useState("");
const [end, setEnd] = useState("");
const [reports, setReports] = useState<Record<string, FactorTestReport>>({});
const [running, setRunning] = useState(false);
const [processed, setProcessed] = useState(0);
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;
apiGet<FactorMeta[]>("/factors")
.then((list) => {
if (!alive) return;
setFactors(list);
const picks = ["momentum_60", "volatility_60"].filter((n) =>
list.some((f) => f.name === n),
);
setChecked(new Set(picks));
})
.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);
return () => {
alive = false;
};
}, []);
function toggleCheck(name: string) {
setChecked((prev) => {
const next = new Set(prev);
if (next.has(name)) next.delete(name);
else next.add(name);
return next;
});
}
function toggleExpand(name: string) {
setExpanded((prev) => {
const next = new Set(prev);
if (next.has(name)) next.delete(name);
else next.add(name);
return next;
});
}
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) {
setError("请至少选择一个因子");
return;
}
setRunning(true);
setError("");
setJobId("");
setReports({});
setProcessed(0);
try {
let index = 0;
for (const name of names) {
index += 1;
setCurrent({ idx: index, total: names.length, name });
const spec: ResearchSpec = {
type: "factor_test",
universe: { exclude_st: true, min_listing_days: 0 },
factors: [{ name, weight: 1 }],
selection: { top_n: 10 },
rebalance: "monthly",
period: [start, end],
};
const { job_id } = await submitJob(spec);
setJobId(job_id);
const out = await waitJob<FactorTestReport>(job_id);
if (out.status === "success" && out.result) {
setReports((prev) => ({ ...prev, [name]: out.result! }));
} else {
const msg = `因子 ${name}:${out.status}${out.error ? `:${out.error}` : ""}`;
setError((prev) => (prev ? `${prev}\n${msg}` : msg));
}
setProcessed(index);
}
} catch (e) {
setError((e as Error).message);
} finally {
setRunning(false);
setCurrent(null);
setJobId("");
}
}
const selectedCount = checked.size;
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
title="因子研究"
sub="勾选行情因子并运行单因子测试(IC / RankIC / 分层)。每个因子独立异步执行并归档为实验,可在「实验」页查看与复跑。"
actions={<Pill tone="accent" icon="flask">{reportNames.length}/{selectedCount} 个已出报告</Pill>}
/>
{error && !running ? (
<Banner tone="error">
<pre style={{ margin: 0, whiteSpace: "pre-line", font: "inherit" }}>{error}</pre>
</Banner>
) : null}
{notice && !running ? <Banner tone="info">{notice}</Banner> : null}
<Card
title="因子目录"
icon="flask"
sub="点击行展开定义 · 勾选后点下方「运行因子测试」"
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} />
</div>
) : factors.length === 0 ? (
<Empty
icon="flask"
title="暂无因子目录"
hint="请确认后端已启动,且 /api/factors 返回因子元数据。"
/>
) : (
<>
<div className="table-wrap">
<table className="tbl">
<thead>
<tr>
<th style={{ width: 44 }}></th>
<th>因子</th>
<th>参数</th>
<th>回看</th>
<th>方向</th>
<th>简介(用法 / 何时有效)</th>
<th style={{ width: 96 }}>操作</th>
</tr>
</thead>
<tbody>
{factors.map((f) => (
<FactorRow
key={f.name}
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>
</table>
</div>
<div className="hint" style={{ padding: "10px 16px", borderTop: "1px solid var(--line)" }}>
计分规则:每个因子在每日横截面做 z-score 标准化(低为好自动取负);组合页按权重叠加得分选股。
</div>
</>
)}
</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="开始日期">
<input type="date" className="input" value={start} onChange={(e) => setStart(e.target.value)} />
</Field>
<Field label="结束日期">
<input type="date" className="input" value={end} onChange={(e) => setEnd(e.target.value)} />
</Field>
<Btn
variant="primary"
icon="play"
loading={running}
disabled={running || selectedCount === 0}
onClick={run}
>
{running
? `运行中 ${processed}/${selectedCount}`
: selectedCount === 0
? "请先勾选因子"
: `运行因子测试(${selectedCount} 个)`}
</Btn>
</div>
{running ? (
<div style={{ marginTop: 14 }}>
<Progress
value={progressPct}
label={
<>
正在运行:<b>{current?.name}</b>({current?.idx}/{current?.total})· 后台任务{" "}
<span className="mono">{jobId || "排队中…"}</span>
</>
}
/>
</div>
) : null}
</Card>
{reportNames.length === 0 && !running && !error ? (
<Card>
<Empty
icon="chartLine"
title="还没有测试报告"
hint="勾选一个或多个因子并设置研究区间后运行;完成后报告会按因子逐一出现在这里。"
/>
</Card>
) : null}
{reportNames.map((name) => {
const meta = factors.find((f) => f.name === name);
return (
<Card
key={name}
icon="chartLine"
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">
已完成
</Pill>
}
>
<ReportView report={reports[name]} />
</Card>
);
})}
</>
);
}
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", opacity: f.enabled === false ? 0.55 : 1 }}
onClick={props.onToggleExpand}
>
<td onClick={(e) => e.stopPropagation()}>
<label className="check check--cell" title={`选择因子 ${f.name}`}>
<input
type="checkbox"
checked={props.checked}
onChange={props.onToggleCheck}
aria-label={`选择因子 ${f.name}`}
/>
</label>
</td>
<td className="cell-strong">
{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>
{/* 算不出来的历史行没有可信方向(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={7}>
<div className="stack" style={{ gap: 8 }}>
{/* 展开行里也会出现长参数键,窄屏同样要能断行 */}
<div style={{ overflowWrap: "anywhere" }}>
<b className="mono">{f.name}</b> · {f.description}
</div>
<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>
) : null}
</>
);
}
function ReportView({ report }: { report: FactorTestReport }) {
const qs = report.quantile_returns ?? [];
const maxAbs = Math.max(0.001, ...qs.map((q) => Math.abs(q.return_pct)));
return (
<div className="stack" style={{ gap: 16 }}>
<div className="metric-grid" style={{ marginBottom: 0 }}>
<Metric
label="IC 均值"
icon="chartLine"
value={report.ic_mean.toFixed(4)}
tone={report.ic_mean >= 0 ? "pos" : "neg"}
/>
<Metric
label="RankIC 均值"
value={report.rank_ic_mean.toFixed(4)}
tone={report.rank_ic_mean >= 0 ? "pos" : "neg"}
/>
<Metric
label="ICIR"
value={report.icir.toFixed(3)}
tone={report.icir >= 1 ? "pos" : "plain"}
sub="越大越稳定"
/>
<Metric
label="正收益占比"
value={`${report.positive_ratio_pct.toFixed(1)}%`}
tone={report.positive_ratio_pct >= 50 ? "pos" : "warn"}
/>
<Metric label="样本日数" value={report.sample_days} icon="calendar" />
</div>
{qs.length > 0 ? (
<div>
<div className="field__label" style={{ marginBottom: 2 }}>
分层表现(Q1 最低因子值 → Q5 最高,未来 21 日平均收益)
</div>
<div className="vbars" role="img" aria-label="分层收益柱状图">
{qs.map((q) => {
const v = q.return_pct;
const h = Math.max(4, Math.round((Math.abs(v) / maxAbs) * 72));
return (
<div className="vbars__item" key={q.quantile}>
<span className="vbars__val">{v >= 0 ? "+" : ""}{v.toFixed(2)}%</span>
<div className={`vbars__bar ${v >= 0 ? "is-pos" : "is-neg"}`} style={{ height: h }} />
<span className="vbars__cap">Q{q.quantile + 1}</span>
</div>
);
})}
</div>
</div>
) : null}
<div className="hint">
读数:IC / RankIC 为正表示与未来收益正相关,ICIR 越大越稳定;分层收益若高分层显著高于低分层说明单调性好。
单因子测试 ≠ 策略有效,需结合样本外与稳健性分析。
</div>
</div>
);
}