Files
qlib/frontend/web/app/factors/page.tsx
T
Simon 1cf7c84897 fix(web): 因子研究/回测改走异步 Job(修复全市场研究长请求被代理中断 500)
- 根因:全市场因子测试同步执行约 47s,经 Next 同源代理转发时长请求被 socket 中断(前端日志:Failed to proxy .../api/factor-tests [socket hang up])
- factors/backtest 页改为 POST /api/jobs 提交 → 轮询 GET /api/jobs/{id} → 取内嵌结果(AGENT §19 异步化)
- 新增 lib/jobs.ts(submitJob / waitJob,超时 10 分钟),运行中显示 Job 号
- 端到端验证:全市场 momentum_60 测试 JOB-93479087 → success(IC -0.0598,样本 323 日)+ Experiment 归档
- tsc + next build 通过;同步接口保留(适合小样本/脚本调用)
2026-09-07 20:38:56 +08:00

156 lines
5.3 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";
export default function FactorsPage() {
const [factors, setFactors] = useState<FactorMeta[]>([]);
const [name, setName] = useState("momentum_60");
const [start, setStart] = useState("2024-03-01");
const [end, setEnd] = useState("2024-12-31");
const [report, setReport] = useState<FactorTestReport | null>(null);
const [running, setRunning] = useState(false);
const [jobId, setJobId] = useState("");
const [error, setError] = useState("");
useEffect(() => {
apiGet<FactorMeta[]>("/factors")
.then(setFactors)
.catch((e: Error) => setError(e.message));
}, []);
async function run() {
setRunning(true);
setError("");
setJobId("");
setReport(null);
try {
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],
};
// 异步 Job:立即返回 job_id,后台执行后轮询取结果(全市场可能数十秒)
const { job_id } = await submitJob(spec);
setJobId(job_id);
const out = await waitJob<FactorTestReport>(job_id);
if (out.status === "success" && out.result) {
setReport(out.result);
} else {
setError(`任务${out.status}${out.error ? `:${out.error}` : ""}`);
}
} catch (e) {
setError((e as Error).message);
} finally {
setRunning(false);
}
}
return (
<>
<h1>因子研究</h1>
<div className="card">
<h2>因子目录(行情因子 · 无未来函数)</h2>
<table>
<thead>
<tr>
<th>名称</th>
<th>描述</th>
<th>公式</th>
<th>回看</th>
<th>方向</th>
</tr>
</thead>
<tbody>
{factors.map((f) => (
<tr key={f.name}>
<td>{f.name}</td>
<td>{f.description}</td>
<td className="muted">{f.formula}</td>
<td>{f.lookback}</td>
<td>{f.direction === "higher_is_better" ? "高为好" : "低为好"}</td>
</tr>
))}
</tbody>
</table>
</div>
<div className="card">
<h2>运行单因子测试(IC / RankIC / 分层)</h2>
<div className="row">
<select value={name} onChange={(e) => setName(e.target.value)}>
{factors.map((f) => (
<option key={f.name} value={f.name}>
{f.name}
</option>
))}
</select>
<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}>
{running ? "后台运行中…" : "运行因子测试"}
</button>
</div>
{running && jobId && <div className="muted">任务 {jobId} 后台执行中,请稍候…</div>}
{error && <div className="error">{error}</div>}
{report && (
<div style={{ marginTop: 16 }}>
<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>
)}
</div>
</>
);
}