"use client"; /** * 回测组合(/backtest)—— 选若干选股策略 + 填回测参数 → 保存为组合 / 直接运行。 * * 2026-09 重构后这里是回测的**唯一产品入口**: * - 选股策略(怎么选股)来自 /strategies; * - 费率 / 复权口径来自 /settings(公共配置,本页只读展示); * - 本页只定「回测时才定的参数」:起始资金、持仓数 N、持仓天数区间 [Tmin,Tmax]、 * 调仓时机(日/周/月)、回测区间。 * * 进入方式(都可分享 URL): * - 直接进入:空组合开始; * - `?combo=CMB-xxx`:载入已保存组合; * - `?strategy=STG-xxx`:预先把该选股策略勾进组合(从策略库「加入回测组合」过来)。 */ import { Suspense, useCallback, useEffect, useMemo, useRef, useState } from "react"; import Link from "next/link"; import { useSearchParams } from "next/navigation"; import { apiDelete, apiGet, apiPost, apiPut } from "@/lib/api"; import { runSavedCombo, submitComboJob, useJobRunner } from "@/lib/jobs"; import { recentRange } from "@/lib/dates"; import type { BacktestCombo, BacktestResult, GlobalConfig, SelectionStrategy } from "@/lib/types"; import { PageHeader, Card, Pill, Btn, Banner, Empty, Loading, Field, Progress, BacktestMetrics } from "@/components/ui"; import { BacktestResultView } from "@/components/BacktestResultView"; import { JobProgress } from "@/components/JobProgress"; import { adjustLabel } from "@/lib/labels"; export default function BacktestPage() { return ( }> ); } interface Draft { name: string; description: string; strategyIds: string[]; initialCapital: number; holdCount: number; holdMinDays: number; holdMaxDays: number | null; // null = 不强制了结 rebalanceFreq: "daily" | "weekly" | "monthly"; start: string; end: string; } function emptyDraft(range: { start: string; end: string }): Draft { return { name: "", description: "", strategyIds: [], initialCapital: 1_000_000, holdCount: 20, holdMinDays: 0, holdMaxDays: null, rebalanceFreq: "monthly", start: range.start, end: range.end, }; } /** * 草稿 → 组合对象。 * * `nameFallback`:**运行**(不保存)时组合名可以为空,但 `/combos/run` 的契约里 * `name` 必填 —— 老版本直接提交空名字,用户看到的是 422 原文 * (`String should have at least 1 character`),点了「运行」却像什么都没发生。 * 运行是临时组合、不会落库,所以这里给一个**如实说明「未命名/仅本次运行」**的名字, * 而不是编一个像真的组合名。要正式命名请用「保存为回测组合」。 */ function draftToCombo(d: Draft, nameFallback?: string): BacktestCombo { return { name: d.name.trim() || nameFallback || "", description: d.description.trim(), strategy_ids: d.strategyIds, initial_capital: d.initialCapital, hold_count: d.holdCount, hold_min_days: d.holdMinDays, hold_max_days: d.holdMaxDays, rebalance_freq: d.rebalanceFreq, period: [d.start, d.end], }; } function comboToDraft(c: BacktestCombo): Draft { return { name: c.name, description: c.description ?? "", strategyIds: [...c.strategy_ids], initialCapital: c.initial_capital, holdCount: c.hold_count, holdMinDays: c.hold_min_days, holdMaxDays: c.hold_max_days, rebalanceFreq: c.rebalance_freq, start: c.period[0], end: c.period[1], }; } /** 调仓时机中文标签(列表与摘要共用,避免两处写法漂移)。 */ const FREQ_LABEL: Record = { daily: "每日", weekly: "每周", monthly: "每月", }; function validateDraft(d: Draft, strategies: SelectionStrategy[]): Record { const e: Record = {}; if (!d.strategyIds.length) e.strategies = "至少选择一个选股策略"; else { const missing = d.strategyIds.filter((id) => !strategies.some((s) => s.id === id)); if (missing.length) e.strategies = `以下选股策略已不存在,请移除:${missing.join(", ")}`; } if (d.holdCount < 1) e.holdCount = "持仓数 N 至少为 1"; if (d.holdMinDays < 0) e.holdMinDays = "Tmin 不能为负"; if (d.holdMaxDays !== null && d.holdMaxDays < 1) e.holdMaxDays = "Tmax 至少为 1 天"; if (d.holdMaxDays !== null && d.holdMinDays > 0 && d.holdMaxDays < d.holdMinDays) e.holdMaxDays = `Tmax=${d.holdMaxDays} 不能小于 Tmin=${d.holdMinDays}`; if (d.initialCapital < 10000) e.capital = "起始资金建议 ≥ 1 万"; if (!d.start || !d.end) e.period = "起止日期都必填"; else if (d.start >= d.end) e.period = "开始日期必须早于结束日期"; return e; } function BacktestInner() { const search = useSearchParams(); const comboId = search.get("combo"); const presetStrategy = search.get("strategy"); const range = useMemo(() => recentRange(), []); const [strategies, setStrategies] = useState([]); const [config, setConfig] = useState(null); const [combos, setCombos] = useState(null); const [draft, setDraft] = useState(emptyDraft(range)); const [savedComboId, setSavedComboId] = useState(null); const [result, setResult] = useState(null); // 本次结果的归档 id:结果区据此提供「新页面放大」(放大页从归档读同一份数据)。 // 同步/未归档的结果没有 id,放大入口会如实说明原因而不是给个坏链接。 const [resultArchiveId, setResultArchiveId] = useState(null); const [running, setRunning] = useState(false); const [runningComboId, setRunningComboId] = useState(null); /** * 作业反馈统一交给 `useJobRunner`(全站同一套):提交瞬间就有「正在提交作业…」, * 之后是真实阶段 + 每秒自增的已用时间 + 作业号 + 可取消 —— 用户反馈的 * 「点了回测没有任何反馈,不知道有没有开始」就是这里的缺口。 */ const job = useJobRunner("回测组合"); const [error, setError] = useState(""); const [notice, setNotice] = useState(""); const [saving, setSaving] = useState(false); const [deletingComboId, setDeletingComboId] = useState(null); const [revealErrors, setRevealErrors] = useState(false); const bootstrapped = useRef(false); /* ---------- 初始化:策略列表 + 公共配置 + 已保存组合 + 来源载入 ---------- */ useEffect(() => { let alive = true; Promise.all([ apiGet("/strategies"), apiGet("/config"), ]) .then(([sts, cfg]) => { if (!alive) return; setStrategies(sts); setConfig(cfg); }) .catch((e: Error) => alive && setError(e.message)); // 组合库单独拉取:即便它失败,也不该连累策略列表与公共配置(否则整页只剩一条错误)。 apiGet("/combos") .then((cbs) => { if (alive) setCombos(cbs); }) .catch((e: Error) => { if (!alive) return; setError(e.message); // 给空列表,否则「组合库」会永远停在加载中 setCombos((prev) => prev ?? []); }); return () => { alive = false; }; }, []); const loadCombos = useCallback(async () => { try { setCombos(await apiGet("/combos")); } catch (e) { setError((e as Error).message); setCombos([]); } }, []); useEffect(() => { if (bootstrapped.current) return; bootstrapped.current = true; (async () => { if (comboId) { try { const c = await apiGet(`/combos/${encodeURIComponent(comboId)}`); setDraft(comboToDraft(c)); setSavedComboId(c.id ?? null); setNotice(`已载入回测组合「${c.name}」(${c.id})`); return; } catch (e) { setError(`回测组合 ${comboId} 载入失败:${(e as Error).message}`); } } if (presetStrategy) { setDraft((d) => ({ ...d, strategyIds: [presetStrategy] })); setNotice(`已把选股策略 ${presetStrategy} 预勾选进组合 —— 可再加其它策略、填好参数后运行。`); } })(); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); const errors = validateDraft(draft, strategies); const blocking = Object.keys(errors); function toggleStrategy(id: string) { setDraft((d) => ({ ...d, strategyIds: d.strategyIds.includes(id) ? d.strategyIds.filter((x) => x !== id) : [...d.strategyIds, id], })); } async function run() { if (blocking.length) { setError(errors[blocking[0]]); setRevealErrors(true); requestAnimationFrame(() => { const el = document.querySelector(".input--invalid, .field--invalid"); el?.scrollIntoView({ block: "center", behavior: "smooth" }); }); return; } // 未命名时用「未命名组合(仅本次运行)」:临时组合不落库,只是给作业一个可读标签; // 不让用户在点「运行」后撞上 422(那正是「点了没反应」的观感来源之一)。 const fallback = draft.name.trim() ? undefined : "未命名组合(仅本次运行)"; if (fallback) { setNotice("本次运行未命名组合名 —— 按「未命名组合(仅本次运行)」提交(不会保存到组合库)。"); } await runVia(() => submitComboJob(draftToCombo(draft, fallback)), null); } /** * 共用的「提交 Job → 轮询 → 出结果」管道。 * * 两条入口共用:① 用页面上的草稿运行(临时组合,不落库);② 从组合库直接运行已保存组合 * (走 POST /combos/{id}/run,跑库里那份参数)。抽出来是为了让两条路径的阶段显示、 * 归档提示、错误处理完全一致,不会一条修了另一条忘了。 */ async function runVia(submit: () => Promise<{ job_id: string }>, comboId: string | null) { setRunning(true); setRunningComboId(comboId); setError(""); setNotice(""); setResult(null); setResultArchiveId(null); try { const out = await job.run(submit, { label: draft.name ? `回测组合「${draft.name}」` : "回测组合", }); if (out.status === "success" && out.result) { setResult(out.result as BacktestResult); const exp = out.experimentId ?? null; setResultArchiveId(exp); setNotice( exp ? `回测完成,已归档为实验 ${exp}(可在「实验对比」页与其它版本对比)。` : "回测完成,已自动归档。", ); } else if (out.status === "cancelled") { // 主动取消不是错误:如实说明,并提示可重新运行 setNotice("已取消该回测任务(未产生结果)。可改完参数后重新运行。"); } else { setError(`任务${out.status}${out.error ? `:${out.error}` : ""}`); } } catch (e) { setError((e as Error).message); } finally { setRunning(false); setRunningComboId(null); } } /* ---------- 组合库操作 ---------- */ /** 把库里的一份组合回填到表单(不自动运行,先把参数摆出来给用户看/改)。 */ function loadCombo(c: BacktestCombo) { setDraft(comboToDraft(c)); setSavedComboId(c.id ?? null); setError(""); setRevealErrors(false); setNotice(`已载入回测组合「${c.name}」(${c.id})—— 可直接运行,或改完参数再「更新组合」。`); } /** 用库里的参数直接运行(不掺入表单里未保存的改动)。 */ async function runSaved(c: BacktestCombo) { if (!c.id) return; setDraft(comboToDraft(c)); setSavedComboId(c.id); await runVia(() => runSavedCombo(c.id as string), c.id); } /** 清空表单,开始一个新组合(不影响库里已有的)。 */ function startNewCombo() { setDraft(emptyDraft(range)); setSavedComboId(null); setResult(null); setNotice("已清空表单,开始一个新组合。"); setError(""); setRevealErrors(false); } async function removeCombo(c: BacktestCombo) { if (!c.id) return; if (!window.confirm(`删除回测组合「${c.name}」(${c.id})?此操作不可撤销(归档结果不受影响)。`)) { return; } setDeletingComboId(c.id); setError(""); try { await apiDelete(`/combos/${encodeURIComponent(c.id)}`); if (savedComboId === c.id) setSavedComboId(null); setNotice(`已删除回测组合「${c.name}」。`); await loadCombos(); } catch (e) { setError((e as Error).message); } finally { setDeletingComboId(null); } } async function saveCombo() { if (blocking.length) { setError(errors[blocking[0]]); setRevealErrors(true); return; } if (!draft.name.trim()) { setError("保存组合需要起个名字"); setRevealErrors(true); return; } setSaving(true); setError(""); try { const combo = draftToCombo(draft); if (savedComboId) { await apiPut(`/combos/${encodeURIComponent(savedComboId)}`, combo); setNotice(`已更新回测组合「${combo.name}」`); } else { const saved = await apiPost("/combos", combo); setSavedComboId(saved.id ?? null); setNotice(`已保存回测组合「${saved.name}」(${saved.id})`); } await loadCombos(); } catch (e) { setError((e as Error).message); } finally { setSaving(false); } } const showErr = (k: string) => (revealErrors ? errors[k] : undefined); return ( <> 选股策略库 公共配置 实验对比 } /> {error ? {error} : null} {notice ? {notice} : null} {/* 公共配置(只读) */} {config ? ( 去修改 }>
佣金 {(config.commission_rate * 100).toFixed(3)}% 印花税 {(config.stamp_tax_rate * 100).toFixed(2)}% 滑点 {(config.slippage_rate * 100).toFixed(2)}% 最低佣金 {config.min_commission} 元 复权 {adjustLabel(config.price_adjustment)} 基准 {config.benchmark}
运行时这些值会被**快照**进归档,事后改公共配置不影响这次结果的数字。
) : null} {/* 组合库:保存过的组合在这里能被找回(否则「保存」等于存进黑洞) */} 新建组合 void loadCombos()}> 刷新 } > {combos === null ? ( ) : combos.length === 0 ? ( ) : (
{combos.map((c) => { const isCurrent = savedComboId === c.id; const isRunning = runningComboId === c.id; return (
{c.name} {isCurrent ? ( 当前载入 ) : null} {isRunning ? 运行中… : null} {c.id}
持仓数 {c.hold_count} 持仓区间 [{c.hold_min_days}, {c.hold_max_days ?? "∞"}] 天 调仓 {FREQ_LABEL[c.rebalance_freq] ?? c.rebalance_freq} 区间 {c.period[0]} ~ {c.period[1]} 引用策略 {c.strategy_ids.length} 个
{c.description ?
{c.description}
: null}
loadCombo(c)}> 载入到表单 void runSaved(c)} > 直接运行 void removeCombo(c)} > 删除
); })}
)}
「直接运行」用库里保存的那份参数(不受表单里未保存改动影响);「载入到表单」把参数摆出来, 改完点上方「更新组合」覆盖,或直接点「运行回测组合」按当前草稿跑。
{/* 选股策略多选 */} 多策略取并集后按 Borda 秩和统一打分排序 }> {strategies.length === 0 ? ( 先去策略库建一个「怎么选」的策略 } /> ) : (
{strategies.map((s) => { const on = draft.strategyIds.includes(s.id ?? ""); return ( ); })}
)} {showErr("strategies") &&
{showErr("strategies")}
}
{/* 回测参数 */} {savedComboId ? "更新组合" : "保存为回测组合"} } >
setDraft({ ...draft, name: e.target.value })} /> setDraft({ ...draft, description: e.target.value })} /> setDraft({ ...draft, initialCapital: Number(e.target.value) })} /> setDraft({ ...draft, holdCount: Number(e.target.value) })} /> setDraft({ ...draft, holdMinDays: Number(e.target.value) })} />
setDraft({ ...draft, holdMaxDays: e.target.value === "" ? null : Number(e.target.value) })} />
setDraft({ ...draft, start: e.target.value })} /> setDraft({ ...draft, end: e.target.value })} />
{showErr("period") &&
{showErr("period")}
}
{running ? "后台运行中…" : "运行回测组合"} N={draft.holdCount} · [{draft.holdMinDays}, {draft.holdMaxDays ?? "∞"}] 天 ·{" "} {draft.rebalanceFreq === "daily" ? "日频" : draft.rebalanceFreq === "weekly" ? "周频" : "月频"} ·{" "} {draft.start} ~ {draft.end} · {draft.strategyIds.length} 个策略 {blocking.length ? ( 有 {blocking.length} 处需要修正 ) : ( 参数校验通过 )}
{/* 反馈条紧贴运行按钮:点了就有字,且在排队阶段就能取消 */} {job.state.phase === "idle" && running ? (
正在提交作业…(提交成功后会显示作业号与真实阶段,可随时取消)
) : null}
{!result && !running && !error ? ( ) : null} {result ? ( ) : null} ); }