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

245 lines
7.8 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 {
PageHeader,
Card,
Pill,
Field,
Btn,
Banner,
Progress,
Empty,
BacktestMetrics,
MonthlyReturnsTable,
UnimplementedNote,
} from "@/components/ui";
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(() => {
let alive = true;
apiGet<FactorMeta[]>("/factors")
.then((list) => {
if (!alive) return;
setFactors(list);
if (list.length > 0 && !list.some((f) => f.name === factor)) {
setFactor(list[0].name);
}
})
.catch((e: Error) => alive && setError(e.message));
const { start: s, end: e } = recentRange();
setStart(s);
setEnd(e);
return () => {
alive = false;
};
}, []);
async function run() {
if (!factor) return;
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);
setJobId("");
}
}
return (
<>
<PageHeader
title="选股回测"
sub="以单个因子的横截面排序构建 Top N 组合并完整回测:净值 / 回撤 / 月度收益 / 持仓明细。"
actions={
result ? (
<Pill tone="pos" icon="check">
最近一次回测已完成
</Pill>
) : null
}
/>
<Card title="研究参数(Research Specification)" icon="gauge">
<div className="form-grid">
<Field label="因子">
<select
className="input"
value={factor}
onChange={(e) => setFactor(e.target.value)}
>
{factors.map((f) => (
<option key={f.name} value={f.name}>
{f.name}
</option>
))}
</select>
</Field>
<Field label="Top N">
<input
type="number"
className="input"
min={1}
max={50}
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={running} disabled={running} onClick={run}>
{running ? "后台运行中…" : "运行回测"}
</Btn>
</div>
{running ? (
<div style={{ marginTop: 14 }}>
<Progress
value={35}
label={
<>
任务 <span className="mono">{jobId || "排队中…"}</span> 后台执行中,请稍候…
</>
}
/>
</div>
) : null}
{error ? <div style={{ marginTop: 12 }}><Banner tone="error">{error}</Banner></div> : null}
</Card>
{!result && !running && !error ? (
<Card>
<Empty
icon="gauge"
title="尚未运行回测"
hint="设置上方参数后运行;完成展示净值曲线、回撤、月度收益与最新持仓。"
/>
</Card>
) : null}
{result ? <ResultView result={result} /> : null}
</>
);
}
function ResultView({ result }: { result: BacktestResult }) {
const s = result.summary;
const last = result.positions.at(-1);
const holdings = result.positions.filter((p) => p.date === last?.date);
return (
<>
<div className="between" style={{ margin: "6px 0 14px" }}>
<div className="row">
<Pill tone="pos" icon="check">完成</Pill>
<Pill tone="violet" icon="target">Top N 等权</Pill>
<Pill>{s.start} ~ {s.end}</Pill>
</div>
<div className="row" style={{ fontSize: 22, fontWeight: 700 }}>
总收益 <SignedText value={s.total_return_pct} />
</div>
</div>
<BacktestMetrics s={s} />
<div className="chart-grid">
<Card icon="chartLine" title="净值曲线" tools={<Pill tone="pos">期末 {s.final_equity.toLocaleString()}</Pill>}>
<LineChart data={result.equity_curve} color="#3ddc97" fill height={300} xFmt={(d) => d.slice(5)} yFmt={(v) => v.toFixed(2)} />
</Card>
<Card icon="chartLine" title="回撤(%)" tools={<Pill tone="neg">最大 {s.max_drawdown_pct.toFixed(2)}%</Pill>}>
<LineChart data={result.drawdown} color="#ff7a7a" height={300} xFmt={(d) => d.slice(5)} yFmt={(v) => v.toFixed(2) + "%"} />
</Card>
</div>
<Card icon="calendar" title="月度收益(%)">
<MonthlyReturnsTable rows={result.monthly_returns} />
</Card>
<Card icon="target" title={`最新持仓 · ${last?.date ?? "—"}`}>
{holdings.length === 0 ? (
<Empty title="无持仓记录" hint="回测区间内没有产生持仓。" />
) : (
<div className="chips">
{holdings.map((p) => (
<span className="chip" key={p.symbol}>
<b className="mono">{p.symbol}</b>
{(p.weight * 100).toFixed(0)}%
</span>
))}
</div>
)}
</Card>
<UnimplementedNote items={result.unimplemented} />
</>
);
}
function SignedText({ value }: { value: number }) {
const cls = value >= 0 ? "tone-pos" : "tone-neg";
return (
<span className={cls}>
{value > 0 ? "+" : ""}
{value.toFixed(2)}%
</span>
);
}