接着 /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,复查无残留)。
875 lines
36 KiB
TypeScript
875 lines
36 KiB
TypeScript
"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>
|
||
);
|
||
} |