Files
qlib/frontend/web/app/factors/compose/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

337 lines
12 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 { useEffect, useState } from "react";
import { apiGet } from "@/lib/api";
import { submitJob, useJobRunner } from "@/lib/jobs";
import { factorLabel, pickableFactors } from "@/lib/factors";
import type { BacktestResult, FactorMeta, ResearchSpec } from "@/lib/types";
import { recentRange } from "@/lib/dates";
import {
PageHeader,
Card,
Pill,
Field,
Btn,
Banner,
Signed,
} from "@/components/ui";
import { BacktestResultView } from "@/components/BacktestResultView";
import { JobProgress } from "@/components/JobProgress";
import { Icon } from "@/components/icons";
interface Pick {
name: string;
weight: number;
}
export default function ComposePage() {
const [factors, setFactors] = useState<FactorMeta[]>([]);
const [picks, setPicks] = useState<Pick[]>([]);
const [topN, setTopN] = useState(10);
const [rebalance, setRebalance] = useState<"monthly" | "weekly">("monthly");
const [excludeSt, setExcludeSt] = useState(true);
const [start, setStart] = useState("");
const [end, setEnd] = useState("");
const [result, setResult] = useState<BacktestResult | null>(null);
/** 本次结果的归档 id:结果区的「新页面放大」从归档读同一份数据 */
const [archiveId, setArchiveId] = useState<string | null>(null);
/**
* 作业反馈统一交给 `useJobRunner`(与 /backtest 同一套):点了就有「正在提交作业…」,
* 之后是后端真实阶段 + 每秒自增的已用时间 + 作业号 + 可取消。
* 此前这里只有一句「任务 JOB-xxx 后台执行中」的假进度条(value 固定 30),
* 用户既不知道提交成功没、也不知道跑到哪一步。
*/
const job = useJobRunner<BacktestResult>("因子组合回测");
const active =
job.state.phase === "submitting" || job.state.phase === "queued" || job.state.phase === "running";
/** 只留「参数不合法 / 目录加载失败」这类**作业之外**的错误;作业自身的失败由反馈条显示后端原文 */
const [error, setError] = useState("");
useEffect(() => {
let alive = true;
apiGet<FactorMeta[]>("/factors")
.then((list) => {
if (!alive) return;
// 停用/算不出来的因子不摆进候选(停用仍可被既有策略解析,只是不该再被选中)
setFactors(pickableFactors(list));
setPicks([
{ name: "momentum_60", weight: 1.5 },
{ name: "volatility_60", weight: 1.0 },
]);
})
.catch((e: Error) => alive && setError(e.message));
const { start: s, end: e } = recentRange();
setStart(s);
setEnd(e);
return () => {
alive = false;
};
}, []);
function togglePick(name: string) {
setPicks((prev) =>
prev.some((p) => p.name === name)
? prev.filter((p) => p.name !== name)
: [...prev, { name, weight: 1 }],
);
}
function setWeight(name: string, weight: number) {
setPicks((prev) => prev.map((p) => (p.name === name ? { ...p, weight } : p)));
}
function removePick(name: string) {
setPicks((prev) => prev.filter((p) => p.name !== name));
}
async function run() {
const valid = picks.filter((p) => Number.isFinite(p.weight) && p.weight > 0);
if (valid.length === 0) {
setError("请选择至少一个因子并设置大于 0 的权重");
return;
}
setError("");
setResult(null);
setArchiveId(null); // 新一次运行:先清掉上一次的归档 id,避免放大到旧结果
const spec: ResearchSpec = {
type: "backtest",
universe: { exclude_st: excludeSt, min_listing_days: 0 },
factors: valid.map((p) => ({ name: p.name, weight: p.weight })),
selection: { top_n: topN },
rebalance,
period: [start, end],
};
const out = await job.run(() => submitJob(spec), {
label: `因子组合回测(${valid.length} 个因子)`,
});
if (out.status === "success" && out.result) {
setResult(out.result);
// 归档 id 交给结果区做「新页面放大」;反馈条自己也会给「打开归档 / 去对比」
setArchiveId(out.experimentId ?? null);
}
// 失败 / 超时 / 取消都只在反馈条上说:后端原文(out.error)、
// 「已取消(不产生归档)」都在 JobProgress 里,页面不再另写一份 Banner 重复同一个失败
}
return (
<>
<PageHeader
title="因子组合"
sub="多因子横截面 z-score 标准化后按权重叠加成综合分,每期取 Top N 等权持有。"
actions={
<Pill tone="accent" icon="layers">
{picks.length} 个因子
</Pill>
}
/>
<Card title="组合方式(如何合成一个分数)" icon="scale">
<ol className="steplist">
<li>选择若干因子并设置权重(默认 1;低为好方向由引擎自动反向)。</li>
<li>
每个交易日:每个因子在全体股票横截面上做 <b>z-score 标准化</b>(当日缺失因子值的股票不参与该因子得分)。
</li>
<li>
综合得分 = <b>Σ(权重 × 标准化后因子得分)</b>,得分越高越优先。
</li>
<li>每期按综合得分取 Top N 等权持有,到下一调仓日再平衡(含成本 / 涨跌停 / 停牌近似)。</li>
</ol>
</Card>
<Card
title="选择因子并设置权重"
icon="target"
sub="权重可为小数(如 1.5 = 更侧重该因子)"
flush
>
<div className="table-wrap">
<table className="tbl">
<thead>
<tr>
<th style={{ width: 46 }}></th>
<th>因子</th>
<th>方向</th>
<th style={{ width: 120 }}>权重</th>
<th>简介</th>
</tr>
</thead>
<tbody>
{factors.map((f) => {
const pick = picks.find((p) => p.name === f.name);
return (
<tr key={f.name} className={pick ? "row-active" : undefined}>
<td>
{/* 勾选框要用 .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">
{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"}>
{f.direction === "higher_is_better" ? "高为好" : "低为好"}
</Pill>
</td>
<td>
<input
type="number"
className="input"
step="0.1"
min="0.1"
style={{ width: 92 }}
value={pick?.weight ?? 1}
disabled={!pick}
onChange={(e) => setWeight(f.name, Number(e.target.value))}
aria-label={`${f.name} 权重`}
/>
</td>
<td className="hint">{f.brief ?? f.description}</td>
</tr>
);
})}
</tbody>
</table>
</div>
</Card>
<Card title="回测条件" icon="gauge">
{picks.length > 0 ? (
<div className="chips" style={{ marginBottom: 14 }}>
{picks.map((p) => (
<span className="chip" key={p.name}>
<b className="mono">{p.name}</b>
权重 {p.weight}
<button
type="button"
className="chip__x"
aria-label={`移除 ${p.name}`}
onClick={() => removePick(p.name)}
>
<Icon name="x" size={12} />
</button>
</span>
))}
</div>
) : (
<div className="hint" style={{ marginBottom: 14 }}>
尚未选择因子 —— 在上方勾选并设置权重。
</div>
)}
<div className="form-grid">
<Field label="Top N">
<input
type="number"
className="input"
min={1}
max={200}
value={topN}
onChange={(e) => setTopN(Number(e.target.value))}
/>
</Field>
<Field label="调仓频率">
<select
className="input"
value={rebalance}
onChange={(e) => setRebalance(e.target.value as "monthly" | "weekly")}
>
<option value="monthly">月度</option>
<option value="weekly">周度</option>
</select>
</Field>
<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>
<Field label="标的范围">
<label className="row" style={{ gap: 6, color: "var(--text-2)", fontSize: 13, cursor: "pointer" }}>
<input type="checkbox" checked={excludeSt} onChange={(e) => setExcludeSt(e.target.checked)} />
剔除 ST
</label>
</Field>
<Btn
variant="primary"
icon="play"
loading={active}
disabled={active || picks.length === 0}
onClick={run}
>
{active ? "后台运行中…" : `回测组合(${picks.length} 个因子)`}
</Btn>
</div>
{/* 反馈条紧贴运行按钮:提交瞬间就有字,排队阶段就能取消,完成后给归档入口 */}
<JobProgress
state={job.state}
onCancel={job.cancel}
onDismiss={job.reset}
anchorId="factors-compose-job-progress"
kind="backtest"
/>
{error ? <div style={{ marginTop: 12 }}><Banner tone="error">{error}</Banner></div> : null}
</Card>
{result ? (
<ResultView
result={result}
params={{ picks, topN, rebalance, excludeSt, start, end }}
archiveId={archiveId}
/>
) : null}
</>
);
}
function ResultView({
result,
params,
archiveId,
}: {
result: BacktestResult;
params: { picks: Pick[]; topN: number; rebalance: string; excludeSt: boolean; start: string; end: string };
archiveId?: string | null;
}) {
const s = result.summary;
return (
<>
<div className="between" style={{ margin: "6px 0 14px" }}>
<div className="row" style={{ flexWrap: "wrap", gap: 8 }}>
<Pill tone="pos" icon="check">
回测完成
</Pill>
<Pill tone="violet" icon="target">Top {params.topN}</Pill>
<Pill>{params.rebalance === "monthly" ? "月度调仓" : "周度调仓"}</Pill>
{params.excludeSt ? <Pill>剔除 ST</Pill> : null}
<Pill>{params.start} ~ {params.end}</Pill>
<Pill>{params.picks.length} 个因子</Pill>
</div>
<div className="row" style={{ fontSize: 22, fontWeight: 700 }}>
区间收益 <Signed value={s.total_return_pct} suffix="%" />
</div>
</div>
{/* 与「回测组合」共用同一个结果视图:买卖理由、因子曲线、放大入口都在那里,
两处各写一份迟早出现「同一个结果两套图」的口径漂移 */}
<BacktestResultView
result={result}
name="因子组合"
archive={archiveId ? { id: archiveId } : undefined}
/>
</>
);
}