"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 [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, 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))} /> 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)}% ))}
)}
); } function SignedText({ value }: { value: number }) { const cls = value >= 0 ? "tone-pos" : "tone-neg"; return ( {value > 0 ? "+" : ""} {value.toFixed(2)}% ); }