Files
qlib/frontend/web/app/backtest/page.tsx
T
Simon e5a23f176d feat(web): M8.6b 回测页做实 + 首页入口更新(零新后端 API)
- backtest 页:成本参数表单(手续费/印花税/滑点 %)+ 初始资金 → ResearchSpec.costs/
  initial_capital;结果新增年度收益卡与成交明细表(买/卖日、买/卖价、收益、累计换手)
- Dashboard 入口卡加入「股票筛选」「交易信号」(6 张)
- lib/types.ts:ResearchSpec 增加 price_adjustment/costs/portfolio/initial_capital,
  Trade 补 entry_price/exit_price
- 页面 200 冒烟 + tsc --noEmit 通过
2026-09-09 00:41:49 +08:00

323 lines
11 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 [commission, setCommission] = useState(0.03); // %
const [stamp, setStamp] = useState(0.05);
const [slippage, setSlippage] = useState(0.1);
const [capital, setCapital] = useState(1_000_000);
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,
costs: {
commission_rate: commission / 100,
stamp_tax_rate: stamp / 100,
slippage_rate: slippage / 100,
},
initial_capital: capital,
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 className="input" type="number" step="0.01" min={0} value={commission}
onChange={(e) => setCommission(Number(e.target.value))} />
</Field>
<Field label="印花税率 %">
<input className="input" type="number" step="0.01" min={0} value={stamp}
onChange={(e) => setStamp(Number(e.target.value))} />
</Field>
<Field label="滑点率 %">
<input className="input" type="number" step="0.01" min={0} value={slippage}
onChange={(e) => setSlippage(Number(e.target.value))} />
</Field>
<Field label="初始资金(元)">
<input className="input" type="number" step={100000} min={10000} value={capital}
onChange={(e) => setCapital(Number(e.target.value))} />
</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>
{result.yearly_returns.length ? (
<Card icon="calendar" title="年度收益(%)" tools={<Pill>均值 {(result.yearly_returns.reduce((a, y) => a + y.return_pct, 0) / result.yearly_returns.length).toFixed(2)}%</Pill>}>
<div className="chips">
{result.yearly_returns.map((y) => (
<span className="chip" key={y.year}>
<b>{y.year}</b>
<span className={y.return_pct >= 0 ? "tone-pos" : "tone-neg"}>
{y.return_pct.toFixed(2)}%
</span>
</span>
))}
</div>
</Card>
) : null}
{result.trades.length ? (
<Card
icon="scale"
title={`成交明细 · ${result.trades.length} 笔`}
tools={<Pill>{result.turnover_pct.toFixed(2)}% 累计换手</Pill>}
>
<div className="table-wrap">
<table className="tbl">
<thead>
<tr>
<th>买入日</th>
<th>卖出日</th>
<th>代码</th>
<th>买价</th>
<th>卖价</th>
<th>收益</th>
</tr>
</thead>
<tbody>
{result.trades.map((t) => (
<tr key={`${t.symbol}-${t.entry_date}-${t.exit_date}`}>
<td className="mono dim">{t.entry_date}</td>
<td className="mono dim">{t.exit_date}</td>
<td className="mono"><b>{t.symbol}</b></td>
<td className="mono">{t.entry_price?.toFixed(2) ?? "-"}</td>
<td className="mono">{t.exit_price?.toFixed(2) ?? "-"}</td>
<td className={t.return_pct >= 0 ? "tone-pos" : "tone-neg"}>
{t.return_pct.toFixed(2)}%
</td>
</tr>
))}
</tbody>
</table>
</div>
</Card>
) : null}
<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>
);
}