"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([]); 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(null); const [running, setRunning] = useState(false); const [jobId, setJobId] = useState(""); const [error, setError] = useState(""); useEffect(() => { let alive = true; apiGet("/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(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 ( <> 最近一次回测已完成 ) : null } />
setTopN(Number(e.target.value))} /> setCommission(Number(e.target.value))} /> setStamp(Number(e.target.value))} /> setSlippage(Number(e.target.value))} /> setCapital(Number(e.target.value))} /> setStart(e.target.value)} /> setEnd(e.target.value)} /> {running ? "后台运行中…" : "运行回测"}
{running ? (
任务 {jobId || "排队中…"} 后台执行中,请稍候… } />
) : null} {error ?
{error}
: null}
{!result && !running && !error ? ( ) : null} {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 ( <>
完成 Top N 等权 {s.start} ~ {s.end}
总收益
期末 {s.final_equity.toLocaleString()}}> d.slice(5)} yFmt={(v) => v.toFixed(2)} /> 最大 {s.max_drawdown_pct.toFixed(2)}%}> d.slice(5)} yFmt={(v) => v.toFixed(2) + "%"} />
{holdings.length === 0 ? ( ) : (
{holdings.map((p) => ( {p.symbol} {(p.weight * 100).toFixed(0)}% ))}
)}
{result.yearly_returns.length ? ( 均值 {(result.yearly_returns.reduce((a, y) => a + y.return_pct, 0) / result.yearly_returns.length).toFixed(2)}%}>
{result.yearly_returns.map((y) => ( {y.year} = 0 ? "tone-pos" : "tone-neg"}> {y.return_pct.toFixed(2)}% ))}
) : null} {result.trades.length ? ( {result.turnover_pct.toFixed(2)}% 累计换手} >
{result.trades.map((t) => ( ))}
买入日 卖出日 代码 买价 卖价 收益
{t.entry_date} {t.exit_date} {t.symbol} {t.entry_price?.toFixed(2) ?? "-"} {t.exit_price?.toFixed(2) ?? "-"} = 0 ? "tone-pos" : "tone-neg"}> {t.return_pct.toFixed(2)}%
) : null} ); } function SignedText({ value }: { value: number }) { const cls = value >= 0 ? "tone-pos" : "tone-neg"; return ( {value > 0 ? "+" : ""} {value.toFixed(2)}% ); }