Files
qlib/frontend/web/app/factors/page.tsx
T
Simon a77d3c13c3 feat(web): 因子研究升级 + 因子组合页
- 因子元数据增加 brief 使用简介(后端 FactorDef + /api/factors 返回,9 个内置因子补齐文案)
- 因子研究页:
  · 时间默认「今日 ~ 近 6 个月」
  · 因子多选,逐个跑 IC/RankIC Job 并分卡展示报告
  · 目录显示使用简介,行点击展开公式/方向/用法详解
  · 新增计分规则说明与「去因子组合」入口
- 因子组合页 /factors/compose:多因子勾选+权重、组合得分规则说明(z-score×权重求和→TopN),
  可配 topN/调仓/区间/ST,一键回测 Job → 净值/回撤/月度/未建模标注
- 回测页时间默认同样近 6 月
- 验证:tsc + next build 通过;组合回测(momentum_60×1.5 + volatility_60×1.0)Job 成功归档
  EXP-330CAAAE(-13.51%,交易 20);后端 factors/research/api 相关测试通过 / ruff clean
2026-09-07 21:36:16 +08:00

274 lines
9.4 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, waitJob } from "@/lib/jobs";
import type { FactorMeta, FactorTestReport, ResearchSpec } from "@/lib/types";
import { recentRange } from "@/lib/dates";
export default function FactorsPage() {
const [factors, setFactors] = useState<FactorMeta[]>([]);
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 [current, setCurrent] = useState<{ idx: number; total: number; name: string } | null>(null);
const [jobId, setJobId] = useState("");
const [error, setError] = useState("");
useEffect(() => {
apiGet<FactorMeta[]>("/factors")
.then((list) => {
setFactors(list);
// 默认勾选两个代表性因子
const picks = ["momentum_60", "volatility_60"].filter((n) => list.some((f) => f.name === n));
setChecked(new Set(picks));
})
.catch((e: Error) => setError(e.message));
// 区间默认:近 6 个月(客户端设置,避免 SSR 日期不一致)
const { start: s, end: e } = recentRange();
setStart(s);
setEnd(e);
}, []);
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;
});
}
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({});
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) {
const report = out.result;
setReports((prev) => ({ ...prev, [name]: report }));
} else {
const msg = `因子 ${name}:${out.status}${out.error ? `:${out.error}` : ""}`;
setError((prev) => (prev ? `${prev}\n${msg}` : msg));
}
}
} catch (e) {
setError((e as Error).message);
} finally {
setRunning(false);
setCurrent(null);
setJobId("");
}
}
const selectedCount = checked.size;
const doneCount = Object.keys(reports).length;
return (
<>
<h1>因子研究</h1>
<div className="card">
<h2>因子目录(行情因子 · 无未来函数)</h2>
<div className="muted" style={{ marginBottom: 8 }}>
勾选因子后可多选;点击行展开详细说明。每个因子运行一次独立的 IC/RankIC 测试并归档。
<a href="/factors/compose" style={{ marginLeft: 8, color: "#38bdf8" }}>
用这些因子搭建选股组合 → 因子组合
</a>
</div>
<table>
<thead>
<tr>
<th></th>
<th>因子</th>
<th>回看</th>
<th>方向</th>
<th>简介(用法 / 何时有效)</th>
</tr>
</thead>
<tbody>
{factors.map((f) => (
<FactorRow
key={f.name}
factor={f}
checked={checked.has(f.name)}
expanded={expanded.has(f.name)}
onToggleCheck={() => toggleCheck(f.name)}
onToggleExpand={() => toggleExpand(f.name)}
/>
))}
</tbody>
</table>
<div className="muted" style={{ marginTop: 6 }}>
计分规则:每因子在每日横截面上 z-score 标准化(低为好自动取负),组合页可按权重叠加得分选股;
本页单个因子测试各自归档为 Experiment,可去「实验」页查看与复跑。
</div>
</div>
<div className="card">
<h2>运行单因子测试(IC / RankIC / 分层)</h2>
<div className="row">
<label className="muted">
开始 <input type="date" value={start} onChange={(e) => setStart(e.target.value)} />
</label>
<label className="muted">
结束 <input type="date" value={end} onChange={(e) => setEnd(e.target.value)} />
</label>
<button
className="primary"
onClick={run}
disabled={running || selectedCount === 0}
>
{running ? `运行中 ${doneCount}/${selectedCount}…` : `运行因子测试(已选 ${selectedCount} 个)`}
</button>
</div>
{running && (
<div className="muted">
正在运行:{current?.name}({current?.idx}/{current?.total})—— 后台任务 {jobId},请稍候…
</div>
)}
{error && <div className="error" style={{ whiteSpace: "pre-line" }}>{error}</div>}
</div>
{Object.entries(reports).map(([name, report]) => {
const meta = factors.find((f) => f.name === name);
return (
<div className="card" key={name}>
<h2>
{name} · 测试报告
{meta?.brief ? (
<span className="muted" style={{ fontWeight: 400, fontSize: 13 }}>
{" "}— {meta.brief}
</span>
) : null}
</h2>
<ReportView report={report} />
</div>
);
})}
</>
);
}
function FactorRow(props: {
factor: FactorMeta;
checked: boolean;
expanded: boolean;
onToggleCheck: () => void;
onToggleExpand: () => void;
}) {
const { factor: f } = props;
return (
<>
<tr style={{ cursor: "pointer" }} onClick={props.onToggleExpand}>
<td onClick={(e) => { e.stopPropagation(); props.onToggleCheck(); }}>
<input type="checkbox" checked={props.checked} onChange={props.onToggleCheck} />
</td>
<td>{f.name}</td>
<td>{f.lookback}</td>
<td>{f.direction === "higher_is_better" ? "高为好" : "低为好"}</td>
<td>{f.brief ?? f.description}</td>
</tr>
{props.expanded && (
<tr>
<td colSpan={5}>
<div style={{ background: "var(--panel-2)", padding: 10, borderRadius: 6 }}>
<b>{f.name}</b> · {f.description}
<div className="muted" style={{ marginTop: 4 }}>
公式:{f.formula};回看 {f.lookback} 个交易日;频率 {f.frequency};
方向:{f.direction === "higher_is_better" ? "因子值越高得分越高" : "因子值越低得分越高(引擎自动反向)"}
</div>
<div style={{ marginTop: 6 }}>{f.brief}</div>
</div>
</td>
</tr>
)}
</>
);
}
function ReportView({ report }: { report: FactorTestReport }) {
return (
<>
<div className="stat-grid">
<div className="stat">
<div className="label">IC 均值</div>
<div className="value">{report.ic_mean.toFixed(4)}</div>
</div>
<div className="stat">
<div className="label">RankIC 均值</div>
<div className="value">{report.rank_ic_mean.toFixed(4)}</div>
</div>
<div className="stat">
<div className="label">ICIR</div>
<div className="value">{report.icir.toFixed(3)}</div>
</div>
<div className="stat">
<div className="label">正收益占比</div>
<div className="value">{report.positive_ratio_pct.toFixed(1)}%</div>
</div>
<div className="stat">
<div className="label">样本日数</div>
<div className="value">{report.sample_days}</div>
</div>
</div>
{report.quantile_returns.length > 0 && (
<table style={{ marginTop: 12 }}>
<thead>
<tr>
<th>分层(1 最低 → 5 最高)</th>
{report.quantile_returns.map((q) => (
<th key={q.quantile}>Q{q.quantile + 1}</th>
))}
</tr>
</thead>
<tbody>
<tr>
<td>未来 21 日平均收益</td>
{report.quantile_returns.map((q) => (
<td key={q.quantile}>{q.return_pct.toFixed(2)}%</td>
))}
</tr>
</tbody>
</table>
)}
<div className="muted" style={{ marginTop: 6 }}>
读数:IC/RankIC 为正表示与未来收益正相关;ICIR 越大越稳定;分层收益若高分层显著高于低分层说明单调性好。
单因子测试≠策略有效,需结合样本外与稳健性分析。
</div>
</>
);
}