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

226 lines
7.6 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 { BacktestResult, FactorMeta, ResearchSpec } from "@/lib/types";
import { recentRange } from "@/lib/dates";
import { LineChart } from "@/components/LineChart";
export default function BacktestPage() {
const [factors, setFactors] = useState<FactorMeta[]>([]);
const [factor, setFactor] = useState("momentum_60");
const [topN, setTopN] = useState(5);
const [rebalance, setRebalance] = useState<"monthly" | "weekly">("monthly");
const [start, setStart] = useState("");
const [end, setEnd] = useState("");
const [excludeSt, setExcludeSt] = useState(true);
const [result, setResult] = useState<BacktestResult | 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));
const { start: s, end: e } = recentRange();
setStart(s);
setEnd(e);
}, []);
async function run() {
setRunning(true);
setError("");
setJobId("");
setResult(null);
try {
const spec: ResearchSpec = {
type: "backtest",
universe: { exclude_st: excludeSt, min_listing_days: 0 },
factors: [{ name: factor, weight: 1 }],
selection: { top_n: topN },
rebalance,
period: [start, end],
};
// 异步 Job:后台执行(全市场可能数十秒),轮询到终态
const { job_id } = await submitJob(spec);
setJobId(job_id);
const out = await waitJob<BacktestResult>(job_id);
if (out.status === "success" && out.result) {
setResult(out.result);
} else {
setError(`任务${out.status}${out.error ? `:${out.error}` : ""}`);
}
} catch (e) {
setError((e as Error).message);
} finally {
setRunning(false);
}
}
const s = result?.summary;
return (
<>
<h1>选股回测</h1>
<div className="card">
<h2>研究参数(Research Specification)</h2>
<div className="form-grid">
<label className="muted">
因子
<select value={factor} onChange={(e) => setFactor(e.target.value)}>
{factors.map((f) => (
<option key={f.name} value={f.name}>
{f.name}
</option>
))}
</select>
</label>
<label className="muted">
Top N
<input
type="number"
min={1}
max={50}
value={topN}
onChange={(e) => setTopN(Number(e.target.value))}
/>
</label>
<label className="muted">
调仓
<select
value={rebalance}
onChange={(e) => setRebalance(e.target.value as "monthly" | "weekly")}
>
<option value="monthly">月度</option>
<option value="weekly">周度</option>
</select>
</label>
<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>
<label className="muted">
<input
type="checkbox"
checked={excludeSt}
onChange={(e) => setExcludeSt(e.target.checked)}
/>{" "}
剔除 ST
</label>
<button className="primary" onClick={run} disabled={running}>
{running ? "后台运行中…" : "运行回测"}
</button>
</div>
{running && jobId && <div className="muted">任务 {jobId} 后台执行中,请稍候…</div>}
{error && <div className="error">{error}</div>}
</div>
{result && s && (
<>
<div className="card">
<h2>
结果 · {s.total_return_pct.toFixed(2)}%{" "}
<span className={s.total_return_pct >= 0 ? "good" : "bad"}>
{s.total_return_pct >= 0 ? "▲" : "▼"}
</span>
</h2>
<div className="stat-grid">
<div className="stat">
<div className="label">期末净值</div>
<div className="value">{s.final_equity.toLocaleString()}</div>
</div>
<div className="stat">
<div className="label">年化收益</div>
<div className="value">{s.annual_return_pct.toFixed(2)}%</div>
</div>
<div className="stat">
<div className="label">Sharpe</div>
<div className="value">{s.sharpe.toFixed(2)}</div>
</div>
<div className="stat">
<div className="label">最大回撤</div>
<div className="value bad">{s.max_drawdown_pct.toFixed(2)}%</div>
</div>
<div className="stat">
<div className="label">胜率(按交易)</div>
<div className="value">{s.win_rate_pct.toFixed(1)}%</div>
</div>
<div className="stat">
<div className="label">交易笔数 / 换手</div>
<div className="value">
{s.total_trades} / {s.avg_turnover_pct.toFixed(0)}%
</div>
</div>
</div>
<div style={{ marginTop: 12 }}>
<LineChart title="净值曲线" data={result.equity_curve} color="#34d399" />
</div>
<LineChart
title="回撤(%)"
data={result.drawdown}
color="#f87171"
/>
</div>
<div className="card">
<h2>月度收益(%)</h2>
<table>
<thead>
<tr>
<th>月份</th>
<th>收益</th>
</tr>
</thead>
<tbody>
{result.monthly_returns.map((m) => (
<tr key={`${m.year}-${m.month}`}>
<td>
{m.year}-{String(m.month).padStart(2, "0")}
</td>
<td className={m.return_pct >= 0 ? "good" : "bad"}>
{m.return_pct.toFixed(2)}%
</td>
</tr>
))}
</tbody>
</table>
</div>
<div className="card">
<h2>最新持仓</h2>
{(() => {
const last = new Map<string, number>();
for (const p of result.positions) last.set(p.date, 0);
const lastDate = [...last.keys()].at(-1);
const pos = result.positions.filter((p) => p.date === lastDate);
return (
<div className="row">
{pos.map((p) => (
<span key={p.symbol} className="stat">
{p.symbol} · {(p.weight * 100).toFixed(0)}%
</span>
))}
</div>
);
})()}
</div>
<div className="card">
<h2>未建模约束(如实标注)</h2>
<ul className="muted">
{result.unimplemented.map((u, i) => (
<li key={i}>{u}</li>
))}
</ul>
</div>
</>
)}
</>
);
}