"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 = { higher_is_better: "越高越好", lower_is_better: "越低越好", }; /** 参数原始值 → 展示值(方向枚举翻译成中文,其余原样;缺值退回模板默认值)。 */ function paramValueText(spec: FactorParam, raw: number | string | undefined | null): string { const value = raw === undefined || raw === null ? spec.default : raw; if (typeof value === "string" && DIRECTION_TEXT[value]) return DIRECTION_TEXT[value]; return String(value); } /** * 参数原始值 → **写进名字里的字面值**(整数写数字、枚举写 higher_is_better)。 * * 与 paramValueText 分开的原因:名字是引擎键,必须原样;中文只用于给人看的文案。 */ function paramRawText(spec: FactorParam, raw: number | string | undefined | null): string { const value = raw === undefined || raw === null ? spec.default : raw; return String(value); } /** 参数允许范围 / 枚举文案:展开行与新建表单共用一份,避免两处说法分叉。 */ function paramRangeText(spec: FactorParam): string { if (spec.kind === "int") { const { minimum: min, maximum: max } = spec; if (min !== undefined && min !== null && max !== undefined && max !== null) { return `${min} ~ ${max} 的整数`; } return "整数"; } const choices = spec.choices ?? []; if (choices.length === 0) return "受控枚举"; return choices.map((c) => DIRECTION_TEXT[c] ?? c).join(" / "); } /** 单个参数的「标签 + 值」片段;方向这类不重复写标签(值本身已经是中文说法)。 */ function paramPartText(spec: FactorParam, raw: number | string | undefined | null): string { const value = paramValueText(spec, raw); return spec.name === "direction" ? value : `${spec.label} ${value}`; } /** * 目录里的「参数」摘要:`窗口 60 · 越高越好`。 * * 没有 int 参数的因子是逐日时点值(如股息率),补一句「时点值(无窗口)」—— * 否则「没有窗口」和「窗口是 0」在界面上分不出来。 */ function paramSummary(f: FactorMeta): string { const specs = f.param_specs ?? []; if (specs.length === 0) return f.resolvable === false ? "无参数声明" : "无参数"; const params = f.params ?? {}; const parts = specs.map((s) => paramPartText(s, params[s.name])); if (!specs.some((s) => s.kind === "int")) parts.unshift("时点值(无窗口)"); return parts.join(" · "); } /** * 来源文案。 * * 为什么先判 resolvable:库里算不出来的历史手登记行,`source` 只是 DB 默认值 builtin, * 直接读它会把它说成「内置实例」——那是假话。 */ function sourceText(f: FactorMeta): string { if (f.resolvable === false) return "历史手工登记行"; return f.source === "custom" ? "目录里的参数化实例" : "内置实例"; } /** 界面显示名:优先后端给的中文名(含参数),没有才退回引擎键。 */ function factorLabel(f: FactorMeta): string { return f.label || f.name; } /** 新建表单初值:模板默认值,并以 param_specs 的 default 兜底(defaults 缺项也不空着)。 */ function defaultsOf(t: FactorTemplate): Record { const out: Record = {}; for (const s of t.param_specs) out[s.name] = t.defaults[s.name] ?? s.default; return out; } /** * 预览因子键:严格按 param_specs 的顺序拼 `模板名(参数=值,...)`(方向天然在最后)。 * * 与后端 canonical_key 同一套形状 —— 预览错了就等于骗人,所以这里不做任何本地化/重排。 */ function previewKey(t: FactorTemplate, values: Record): string { const args = t.param_specs.map((s) => `${s.name}=${paramRawText(s, values[s.name])}`); return `${t.name}(${args.join(",")})`; } /** 预览中文名:镜像后端 _default_label(非方向参数用「、」,方向用「,」接在最后)。 */ function previewLabel(t: FactorTemplate, values: Record): string { const parts: string[] = []; let direction = ""; for (const s of t.param_specs) { const value = paramValueText(s, values[s.name]); if (s.name === "direction") direction = value; else parts.push(`${s.label} ${value}`); } const inner = [parts.join("、"), direction].filter(Boolean).join(","); return `${t.label}(${inner})`; } /** * 模板公式里的 `{参数}` 占位符按**当前表单值**渲染(镜像后端 _render)。 * * 为什么不能在预览里直接摆模板原文:模板公式写的是 `close / close.shift({window}) - 1`, * 而真实因子的公式是 `…shift(90)…` —— 摆原文会让预览和创建后的口径看起来不一致。 */ function renderFormula(t: FactorTemplate, values: Record): string { return t.formula.replace(/\{(\w+)\}/g, (raw, key: string) => { const spec = t.param_specs.find((s) => s.name === key); return spec ? paramRawText(spec, values[key]) : raw; }); } /** * 从 lib/api 抛出的错误里取出后端的 `detail` 原文。 * * 为什么:apiPost 的报错是 `POST /factors → 422: {"detail":"…"}` 整串文本,而 422 的 detail * 已经是给人看的中文(含允许范围、已有重名)—— 必须原样转述;界面自己再写一遍错误文案, * 就会和后端的受控范围说法分叉。 */ function apiDetail(e: unknown): string { const raw = e instanceof Error ? e.message : String(e); const start = raw.indexOf("{"); if (start >= 0) { try { const body = JSON.parse(raw.slice(start)) as { detail?: unknown }; if (typeof body.detail === "string" && body.detail) return body.detail; } catch { /* 响应体不是 JSON(如 500 的纯文本):退回整串信息,至少不丢状态码 */ } } return raw; } export default function FactorsPage() { const [factors, setFactors] = useState([]); const [templates, setTemplates] = useState([]); const [loading, setLoading] = useState(true); const [checked, setChecked] = useState>(new Set()); const [expanded, setExpanded] = useState>(new Set()); const [start, setStart] = useState(""); const [end, setEnd] = useState(""); const [reports, setReports] = useState>({}); /** * 作业反馈统一交给 `useJobRunner`:本页是「一个因子一个 Job」的批量场景, * 每轮把 (第 i/共 n 个) 作为 progress 交给反馈条 —— 阶段、作业号、已用秒数全部来自后端, * 不再由前端按 processed/selected 算一个百分比假进度条(用户看不出到底跑到哪了)。 */ const job = useJobRunner("单因子测试"); /** 反馈条是否处于「跑着」的状态:用于禁用按钮、让位给反馈条的提示位。 */ 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>({}); const [creating, setCreating] = useState(false); const [created, setCreated] = useState<{ name: string; label?: string } | null>(null); const [createErr, setCreateErr] = useState(""); /** 模板清单是否还在加载:与目录分开,避免「还没加载完」被说成「没加载出来」。 */ const [tplLoading, setTplLoading] = useState(true); /** 正在切换开关的因子名(只禁用那一行,不冻结全表)。 */ const [toggling, setToggling] = useState(""); /** 只刷新目录列表:不动勾选 / 展开状态(新建或停用后刷新不该清掉用户的选择)。 */ const refresh = useCallback(async () => { const list = await apiGet("/factors"); setFactors(list); return list; }, []); useEffect(() => { let alive = true; apiGet("/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("/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("/factors", { template: tpl.name, params: paramValues }); setCreated({ name: row.name, label: row.label }); await refresh(); } catch (e) { setCreateErr(apiDetail(e)); } finally { setCreating(false); } } async function toggleEnabled(f: FactorMeta) { setToggling(f.name); setNotice(""); setError(""); try { const row = await apiPatch("/factors", { name: f.name, enabled: !f.enabled }); setNotice( row.enabled ? `因子 ${row.name} 已启用,重新出现在选择列表里。` : `因子 ${row.name} 已停用:它仍留在目录里(既有策略 / 归档仍按它的名字解析),只是不再出现在选择列表里。`, ); // 停用后照样刷新而不是本地隐藏 —— 停用的行必须还在目录里看得见 await refresh(); } catch (e) { setError(apiDetail(e)); } finally { setToggling(""); } } async function run() { const names = factors.map((f) => f.name).filter((n) => checked.has(n)); if (names.length === 0) { 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 ( <> {reportNames.length}/{selectedCount} 个已出报告} /> {error && !active ? (
{error}
) : null} {notice && !active ? {notice} : null} 已选 {selectedCount} 个} flush > {/* 目录的性质说明:放在 loading 分支之外,加载中也能看到(也是 SSR 可断言的静态文案)。 参数化之后这里必须说清新分工:口径文案仍以代码为准,参数则靠「新建参数化因子」来改。 overflowWrap:示例键里没有空格,窄屏必须能断行,否则整页会横向溢出。 */}
目录是代码注册表(quant/factors.py)的投影: 口径文案(描述 / 公式 / 方向 / 回看)由引擎决定并自动同步(手改会在下次读取时被纠正回代码文本)。 参数(窗口、方向)可以改,改的方式是「从模板新建一个参数化因子」—— 参数写进因子的名字里(如 momentum(window=90,direction=higher_is_better)), 所以新因子是一个新身份:旧因子、既有策略与归档都按各自名字里的参数计算, 不会变义。参数只在模板给定的受控范围内可选 / 可填,越界会被后端拒绝(不会静默截断成边界值)。 依赖列(requires)仍不可改 —— 它是「引擎能不能算」的事实,不是配置。
{loading ? (
) : factors.length === 0 ? ( ) : ( <>
{factors.map((f) => ( toggleCheck(f.name)} onToggleExpand={() => toggleExpand(f.name)} onToggleEnabled={() => void toggleEnabled(f)} /> ))}
因子 参数 回看 方向 简介(用法 / 何时有效) 操作
计分规则:每个因子在每日横截面做 z-score 标准化(低为好自动取负);组合页按权重叠加得分选股。
)}
创建中 : undefined} > {tplLoading ? ( ) : templates.length === 0 ? (
模板清单没加载出来(GET /api/factors/templates 未返回模板), 所以暂时无法新建参数化因子;目录本身不受影响。
{createErr ? {createErr} : null}
) : (
{/* 受控表单:参数从模板 param_specs 来,顺序也照它(方向恒在最后) */} {tplSpecs.map((s) => s.kind === "int" ? ( setParam(s.name, e.target.value)} /> ) : ( ), )} 新建参数化因子
{tpl ? ( /* overflowWrap:预览键是长且无空格的引擎键,窄屏要能断行 */
创建预览
因子键:{previewKey(tpl, paramValues)} {" "}(参数顺序与模板一致,方向恒在最后;这个键就是身份)
中文名:{previewLabel(tpl, paramValues)} {" "}· 公式 {renderFormula(tpl, paramValues)}
中文名按引擎的通用规则预览(量比这类有定制命名的模板,创建后以引擎返回的名字为准)。
) : null} {created ? ( 已创建因子 {created.name} {created.label ? <>({created.label}) : null}:参数已经写进名字里, 这是一个新身份 —— 旧因子与既有策略不变义。 ) : null} {createErr ? {createErr} : null}
参数相同不会重复创建:同一模板、同一组参数只对应一个因子键,重复提交会收到后端的重名提示。 要换参数就再建一个(新键),不要指望改旧键 —— 旧键被改了,引用它的策略与归档就会变义。
)}
执行中 : undefined}>
setStart(e.target.value)} /> setEnd(e.target.value)} /> {/* 按钮上只保留「跑第几个」这一条信息;秒表 / 阶段 / 作业号都在反馈条上,不重复 */} {active ? `运行中 ${job.state.progress?.index ?? 0}/${job.state.progress?.total ?? selectedCount}` : selectedCount === 0 ? "请先勾选因子" : `运行因子测试(${selectedCount} 个)`}
{/* 反馈条:点了立刻有字(正在提交作业…),随后是真实阶段 + 每秒自增的已用时间 + 可取消 */}
{reportNames.length === 0 && !active && !error ? ( ) : null} {reportNames.map((name) => { const meta = factors.find((f) => f.name === name); return ( {name} · {meta.brief} ) : ( meta?.brief ) } tools={ 已完成 } > ); })} ); } function FactorRow(props: { factor: FactorMeta; checked: boolean; expanded: boolean; toggling: boolean; onToggleCheck: () => void; onToggleExpand: () => void; onToggleEnabled: () => void; }) { const { factor: f } = props; const label = factorLabel(f); // 开关只对「目录里创建的参数化实例」开放:内置实例的开关由代码决定(后端会 422), // 算不出来的历史行也不给开关(后端拒绝,且开关本来就没有意义) const canToggle = f.source === "custom" && f.resolvable !== false; const high = f.direction === "higher_is_better"; return ( <> e.stopPropagation()}> {label} {/* 名字就是身份:中文名旁边的引擎键必须看得见(含参数,如 momentum(window=90,…)) */} {f.label && f.label !== f.name ? ( {f.name} ) : null} {f.enabled === false ? <> 已停用 : null} {f.resolvable === false ? <> 引擎算不出来 : null} {paramSummary(f)} {f.lookback} 日 {/* 算不出来的历史行没有可信方向(direction 只是 DB 默认值),不摆一个假的方向 */} {f.resolvable === false ? ( — ) : ( {high ? "越高越好" : "越低越好"} )} {f.brief ?? f.description} e.stopPropagation()}> {canToggle ? ( {f.enabled === false ? "启用" : "停用"} ) : ( 停用 )} {props.expanded ? (
{/* 展开行里也会出现长参数键,窄屏同样要能断行 */}
{f.name} · {f.description}
参数(写进名字里的身份)
{(f.param_specs ?? []).length > 0 ? ( (f.param_specs ?? []).map((s) => (
{s.name} {paramValueText(s, (f.params ?? {})[s.name])} {/* 枚举参数的 note 往往已把可选值抄了一遍(如方向),就不再重复一次 */} {s.kind === "int" ? `允许 ${paramRangeText(s)}${s.note ? ` · ${s.note}` : ""}` : `可选 ${paramRangeText(s)}${ s.note && !s.note.includes(paramRangeText(s)) ? ` · ${s.note}` : "" }`}
)) ) : (
该因子没有声明可编辑参数{f.resolvable === false ? "(历史手工登记行)" : "(时点值口径,无窗口)"}。
)}
公式:{f.formula};频率 {f.frequency};回看 {f.lookback} 个交易日; 方向: {f.resolvable === false ? "未知(引擎算不出来)" : high ? "因子值越高得分越高" : "因子值越低得分越高(引擎自动反向)"}
依赖列: {f.requires && f.requires.length > 0 ? ( {f.requires.join(" / ")} ) : ( "无" )} (引擎能不能算的事实,不可改)
{sourceText(f)} {f.template ? ( 模板 {f.template} ) : null} {f.enabled === false ? "已停用" : "启用中"} {f.enabled === false ? ( 既有策略 / 归档仍按它的名字解析,停用只是不出现在选择列表里。 ) : null}
{f.resolvable === false ? ( 引擎算不出来(历史手工登记行),引用时会报错:这不是「配置不一致」, 而是代码注册表里没有能解析它的模板 / 参数。 ) : null} {f.brief ?
{f.brief}
: null}
) : 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 (
= 0 ? "pos" : "neg"} /> = 0 ? "pos" : "neg"} /> = 1 ? "pos" : "plain"} sub="越大越稳定" /> = 50 ? "pos" : "warn"} />
{qs.length > 0 ? (
分层表现(Q1 最低因子值 → Q5 最高,未来 21 日平均收益)
{qs.map((q) => { const v = q.return_pct; const h = Math.max(4, Math.round((Math.abs(v) / maxAbs) * 72)); return (
{v >= 0 ? "+" : ""}{v.toFixed(2)}%
= 0 ? "is-pos" : "is-neg"}`} style={{ height: h }} /> Q{q.quantile + 1}
); })}
) : null}
读数:IC / RankIC 为正表示与未来收益正相关,ICIR 越大越稳定;分层收益若高分层显著高于低分层说明单调性好。 单因子测试 ≠ 策略有效,需结合样本外与稳健性分析。
); }