Files
qlib/frontend/web/app/factors/page.tsx
T
Simon 24f98e90c6 feat(web): 按 ui-ux-pro-max 设计系统重构前端(侧边导航 + 组件库 + 图标 + 可视化)
- 设计令牌与组件体系:语义色/间距/圆角/状态 + Card/Metric/Pill/Btn/Field/Empty/Skeleton/Progress 等
- 桌面左侧导航 + 窄屏抽屉(usePathname 高亮、ESC/遮罩关闭);修复抽屉/遮罩占据网格把 main 挤到第二行的问题
- 自绘内联 SVG 图标集替代 emoji;图标/表格/按钮等交互态齐全,空态/加载/错误/禁用态补齐
- 因子研究分层柱状、因子组合与回测净值/回撤双图 + 月度收益热力表、实验页任务状态徽标
- LineChart 主题化并按需引入 echarts(回测/组合首载 JS 448k→281k);next.config 放行局域网 dev 来源
2026-09-08 21:48:20 +08:00

367 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, waitJob } from "@/lib/jobs";
import type { FactorMeta, FactorTestReport, ResearchSpec } from "@/lib/types";
import { recentRange } from "@/lib/dates";
import {
PageHeader,
Card,
Metric,
Pill,
Field,
Btn,
Banner,
Progress,
Empty,
SkeletonLines,
} from "@/components/ui";
export default function FactorsPage() {
const [factors, setFactors] = useState<FactorMeta[]>([]);
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>>({});
const [running, setRunning] = useState(false);
const [processed, setProcessed] = useState(0);
const [current, setCurrent] = useState<{ idx: number; total: number; name: string } | null>(null);
const [jobId, setJobId] = useState("");
const [error, setError] = useState("");
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) => setError(e.message))
.finally(() => alive && setLoading(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;
});
}
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({});
setProcessed(0);
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) {
setReports((prev) => ({ ...prev, [name]: out.result! }));
} else {
const msg = `因子 ${name}:${out.status}${out.error ? `:${out.error}` : ""}`;
setError((prev) => (prev ? `${prev}\n${msg}` : msg));
}
setProcessed(index);
}
} catch (e) {
setError((e as Error).message);
} finally {
setRunning(false);
setCurrent(null);
setJobId("");
}
}
const selectedCount = checked.size;
const reportNames = Object.keys(reports);
const progressPct = selectedCount === 0 ? 0 : Math.round((processed / selectedCount) * 100);
return (
<>
<PageHeader
title="因子研究"
sub="勾选行情因子并运行单因子测试(IC / RankIC / 分层)。每个因子独立异步执行并归档为实验,可在「实验」页查看与复跑。"
actions={<Pill tone="accent" icon="flask">{reportNames.length}/{selectedCount} 个已出报告</Pill>}
/>
{error && !running ? (
<Banner tone="error">
<pre style={{ margin: 0, whiteSpace: "pre-line", font: "inherit" }}>{error}</pre>
</Banner>
) : null}
<Card
title="因子目录"
icon="flask"
sub="点击行展开定义 · 勾选后点下方「运行因子测试」"
tools={<Pill tone="violet">已选 {selectedCount} 个</Pill>}
flush
>
{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>
</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>
<div className="hint" style={{ padding: "10px 16px", borderTop: "1px solid var(--line)" }}>
计分规则:每个因子在每日横截面做 z-score 标准化(低为好自动取负);组合页按权重叠加得分选股。
</div>
</>
)}
</Card>
<Card title="运行单因子测试" icon="play" tools={running ? <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={running}
disabled={running || selectedCount === 0}
onClick={run}
>
{running
? `运行中 ${processed}/${selectedCount}`
: selectedCount === 0
? "请先勾选因子"
: `运行因子测试(${selectedCount} 个)`}
</Btn>
</div>
{running ? (
<div style={{ marginTop: 14 }}>
<Progress
value={progressPct}
label={
<>
正在运行:<b>{current?.name}</b>({current?.idx}/{current?.total})· 后台任务{" "}
<span className="mono">{jobId || "排队中…"}</span>
</>
}
/>
</div>
) : null}
</Card>
{reportNames.length === 0 && !running && !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={name}
sub={meta?.brief}
tools={
<Pill tone="pos" icon="check">
已完成
</Pill>
}
>
<ReportView report={reports[name]} />
</Card>
);
})}
</>
);
}
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()}>
<input
type="checkbox"
checked={props.checked}
onChange={props.onToggleCheck}
aria-label={`选择因子 ${f.name}`}
/>
</td>
<td className="cell-strong">
<span className="mono">{f.name}</span>
</td>
<td>
<span className="tag tag--mono">{f.lookback} 日</span>
</td>
<td>
<Pill tone={f.direction === "higher_is_better" ? "accent" : "violet"}>
{f.direction === "higher_is_better" ? "高为好" : "低为好"}
</Pill>
</td>
<td className="hint">{f.brief ?? f.description}</td>
</tr>
{props.expanded ? (
<tr className="expand-cell">
<td colSpan={5}>
<div>
<b className="mono">{f.name}</b> · {f.description}
<div className="hint" style={{ marginTop: 6 }}>
公式:<span className="mono">{f.formula}</span>;回看 {f.lookback} 个交易日;频率 {f.frequency};
方向:
{f.direction === "higher_is_better" ? "因子值越高得分越高" : "因子值越低得分越高(引擎自动反向)"}
</div>
{f.brief ? <div className="hint" style={{ marginTop: 6 }}>{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>
);
}