Files
qlib/frontend/web/app/factors/page.tsx
T
Simon e58367af27 feat(web): 因子研究/因子组合/股票筛选接入统一作业反馈;阶段圆点按作业类型区分
接着 /backtest 的那次改造,把其余会跑异步 Job 的页面也切到同一套
`useJobRunner` + `JobProgress`(用户原话:包括因子测试等所有测试都帮我完善用户反馈):

- **/factors**(一个因子一个 Job 的批量场景):删掉 `running/processed/current/jobId` 与
  按「已处理数 ÷ 总数」自算的 `Progress` **假百分比**;每轮把 `第 i/共 n 个` 交给反馈条,
  阶段/作业号/已用秒数全部来自后端。取消 = 用户明确意图 → 保留已出的报告、不再跑后续因子,
  不报错;单个因子失败仍继续跑其余因子,并把后端原文累积成**批级清单**(多因子时反馈条
  只能显示最后一个作业,前几个失败不能丢)。
- **/factors/compose**:删掉 `value={30}` 的假进度条;`archiveId` / 复用 `BacktestResultView` /
  「新页面放大」全部保留;failed/cancelled 交给反馈条,页面 error 只留参数与目录错误
  (同一失败不在两处各说一遍)。
- **/selection**:异步选股切反馈条;**同步**的「执行选股」保留原 loading(`POST /selections`
  没有 job_id,套上会去 `GET /jobs/{signal}` 撞 404)。
- **/signals**:`POST /api/signals` 是同步接口,**不套**作业反馈(不编作业号、不编阶段),
  改为点击即现的 `role="status"` 提示,如实写明「同步请求、请求期间不能关页、无阶段无取消、
  出错显示后端原文」。
- **阶段圆点按作业类型区分**(修掉一个真实缺陷):原来全站共用一张含 `queued/done` 的
  `STAGE_ORDER`,因子测试页实测出现过**裸英文** `factor_calculation` 且 4 个圆点全灰
  (`indexOf` = -1),还画出了因子测试根本不存在的「逐择股日选股 / 撮合与净值结算」。
  现在 `STAGE_PIPELINES = { factor_test: [加载→计算因子值→汇总], backtest: [加载→撮合→汇总],
  selection: [逐择股日选股] }`(阶段序列**不含 queued/done**,那是作业状态不是阶段),
  未知阶段显示「执行中(stage)」并保留已推进的圆点,不整排灰、不露裸枚举。

验证(真实浏览器 CDP,读数原文已记录):
- /factors:点击后 0.2s 内 `submitting`→`queued` + 作业号;+30s Pill「计算因子值」、
  圆点 `✓ 加载行情与因子数据 / ● 计算因子值 / ○ 汇总指标与曲线`(无「选股/撮合」、无英文枚举);
  成功态给「去对比 / 打开归档」。
- /selection:圆点只有 `逐择股日选股` 一段;取消 → 「已取消,没有归档」;
  另实测撞并发上限时如实显示后端原文「系统繁忙:并发研究任务已达上限」。
- /factors/compose:`submitting→queued→撮合与净值结算→success(EXP-…)`,业务结果与 5 处放大入口照旧。
- /backtest 回归:圆点由 4 个变 3 个(去掉后端**从不上报**的 selection 阶段),取消仍「已取消 + 没归档」。
- 自检产生的 12 个实验归档已全部删除(bulk-delete count:12,复查无残留)。
2026-10-01 18:37:25 +08:00

875 lines
36 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, useJobRunner } 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,
Empty,
SkeletonLines,
} from "@/components/ui";
import { JobProgress } from "@/components/JobProgress";
/**
* 因子研究页。
*
* 参数化(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>>({});
/**
* 作业反馈统一交给 `useJobRunner`:本页是「一个因子一个 Job」的批量场景,
* 每轮把 (第 i/共 n 个) 作为 progress 交给反馈条 —— 阶段、作业号、已用秒数全部来自后端,
* 不再由前端按 processed/selected 算一个百分比假进度条(用户看不出到底跑到哪了)。
*/
const job = useJobRunner<FactorTestReport>("单因子测试");
/** 反馈条是否处于「跑着」的状态:用于禁用按钮、让位给反馈条的提示位。 */
const active =
job.state.phase === "submitting" || job.state.phase === "queued" || job.state.phase === "running";
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;
}
setError("");
setReports({});
// 多因子是**一个因子一个 Job**(后端逐因子归档),所以这里顺序提交、逐个 await;
// 每轮都把进度交给统一反馈条:点了第一个就有「正在提交作业…」+ 作业号,不必等全部跑完。
let index = 0;
for (const name of names) {
index += 1;
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 out = await job.run(() => submitJob(spec), {
label: `单因子测试 · ${name}`,
progress: { index, total: names.length },
});
if (out.status === "success" && out.result) {
setReports((prev) => ({ ...prev, [name]: out.result! }));
} else if (out.status === "cancelled") {
// 取消是用户的明确意图,不是错误:已经跑完的报告留在页面上,后面的因子不再跑
break;
} else {
// 单个因子失败不该吞掉其它因子的报告:把后端原文累积成一份批级错误清单,
// 逐个跑(而不是整批放弃)也和老行为一致
const msg = `因子 ${name}:${out.status}${out.error ? `:${out.error}` : ""}`;
setError((prev) => (prev ? `${prev}\n${msg}` : msg));
}
}
}
const selectedCount = checked.size;
const reportNames = Object.keys(reports);
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 && !active ? (
<Banner tone="error">
<pre style={{ margin: 0, whiteSpace: "pre-line", font: "inherit" }}>{error}</pre>
</Banner>
) : null}
{notice && !active ? <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={active ? <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={active}
disabled={active || selectedCount === 0}
onClick={run}
>
{/* 按钮上只保留「跑第几个」这一条信息;秒表 / 阶段 / 作业号都在反馈条上,不重复 */}
{active
? `运行中 ${job.state.progress?.index ?? 0}/${job.state.progress?.total ?? selectedCount}`
: selectedCount === 0
? "请先勾选因子"
: `运行因子测试(${selectedCount} 个)`}
</Btn>
</div>
{/* 反馈条:点了立刻有字(正在提交作业…),随后是真实阶段 + 每秒自增的已用时间 + 可取消 */}
<JobProgress
state={job.state}
onCancel={job.cancel}
onDismiss={job.reset}
anchorId="factors-job-progress"
kind="factor_test"
/>
</Card>
{reportNames.length === 0 && !active && !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>
);
}