"use client"; /** * 选股回测页 —— 研究闭环里的「定规则 + 验证」环节。 * * 本页承担三种进入方式(都可分享 URL): * - 直接进入:从默认参数开始调; * - `?strategy=STG-xxx`:从策略库载入一个已保存策略(可微调、可另存); * - `?from_selection=SEL-xxx`:从选股结果直通过来,预填当时的条件/因子/TopN * (解决「选出来的股票要手工抄到回测页」的断点); * - `?from_experiment=EXP-xxx`:从实验详情「以此参数再跑一次」进来。 * * 交互改进:参数区与说明/公式同屏(说明随参数实时更新,由后端推导); * 「保存为策略」把当前参数存进策略库;结果区所有股票代码都带名称且可点进个股页。 */ import { Suspense, useEffect, useMemo, useRef, useState } from "react"; import Link from "next/link"; import { useSearchParams } from "next/navigation"; import { apiGet, apiPost } from "@/lib/api"; import { STAGE_LABEL, submitJob, waitJob } from "@/lib/jobs"; import { recentRange } from "@/lib/dates"; import type { ActionRecord, BacktestResult, FactorMeta, ResearchCondition, ResearchSpec, SelectionResult, StrategyDefinition, SymbolCurve, } from "@/lib/types"; import { PageHeader, Card, Pill, Btn, Banner, Empty, Loading } from "@/components/ui"; import { StrategyParamsForm, casePreset, emptyParams, paramsFromSpec, paramsFromStrategy, strategyFromParams, validateParams, type StrategyParams, } from "@/components/StrategyParamsForm"; import { StrategyDocCard } from "@/components/StrategyDocCard"; import { BacktestResultView } from "@/components/BacktestResultView"; import { adjustLabel } from "@/lib/labels"; import { useStrategyDoc } from "@/lib/strategy"; import { SymbolLink, useSymbolNames } from "@/lib/symbols"; type LoadState = { kind: "none" | "strategy" | "selection" | "experiment"; label: string; note?: string }; /** * Next 15 要求使用 `useSearchParams` 的组件处于 Suspense 边界内, * 否则静态渲染阶段会报错(本页用 query 决定「从策略/选股/实验载入」)。 */ export default function BacktestPage() { return ( }> ); } function BacktestInner() { const search = useSearchParams(); const strategyId = search.get("strategy"); const selectionId = search.get("from_selection"); const experimentId = search.get("from_experiment"); const range = useMemo(() => recentRange(), []); const [params, setParams] = useState(null); const [factors, setFactors] = useState([]); const [loaded, setLoaded] = useState({ kind: "none", label: "" }); const [result, setResult] = useState(null); const [running, setRunning] = useState(false); const [jobId, setJobId] = useState(""); const [stage, setStage] = useState(""); const [elapsed, setElapsed] = useState(0); const [error, setError] = useState(""); const [notice, setNotice] = useState(""); const [saving, setSaving] = useState(false); const [lastExp, setLastExp] = useState(null); const [archiveId, setArchiveId] = useState(""); const [restoring, setRestoring] = useState(false); const bootstrapped = useRef(false); /* ---------- 初始化:因子表 + 默认/来源参数 ---------- */ useEffect(() => { let alive = true; apiGet("/factors") .then((list) => alive && setFactors(list)) .catch((e: Error) => alive && setError(e.message)); return () => { alive = false; }; }, []); useEffect(() => { if (bootstrapped.current) return; bootstrapped.current = true; const base = emptyParams(range); try { const prev = window.localStorage.getItem("qlib:last-backtest-experiment"); if (prev) setLastExp(prev); } catch { /* localStorage 不可用(隐私模式):只是少一个便捷入口,不影响主流程 */ } (async () => { // 1) 策略库载入 if (strategyId) { try { const st = await apiGet(`/strategies/${encodeURIComponent(strategyId)}`); setParams({ ...paramsFromStrategy(st, base), start: base.start, end: base.end }); setLoaded({ kind: "strategy", label: `来自策略库:${st.name}(${st.id})`, note: "参数已载入;改完可「另存为新策略」或直接运行回测。", }); return; } catch (e) { setError(`策略 ${strategyId} 载入失败:${(e as Error).message}`); } } // 2) 选股结果直通:把当时的口径原样搬过来 if (selectionId) { try { const sel = await apiGet(`/selections/${encodeURIComponent(selectionId)}`); // 选股把当时的查询原样存进 config_snapshot(复现用),这里据此预填 const snapQ = (sel.config_snapshot ?? {}) as { universe?: { exclude_st?: boolean; min_listing_days?: number }; price_adjustment?: "none" | "qfq" | "hfq"; factors?: { name: string; weight: number }[]; conditions?: ResearchCondition[]; top_n?: number | null; }; const topNQ = snapQ.top_n ?? base.topN; setParams({ ...base, factors: snapQ.factors?.length ? snapQ.factors.map((f) => ({ ...f })) : base.factors, conditions: (snapQ.conditions ?? []).map((c) => ({ ...c })), topN: topNQ, holdX: Math.max(1, Math.min(topNQ, base.holdX)), excludeSt: snapQ.universe?.exclude_st ?? base.excludeSt, minListingDays: snapQ.universe?.min_listing_days ?? base.minListingDays, priceAdjustment: snapQ.price_adjustment ?? base.priceAdjustment, start: base.start, end: base.end, }); setLoaded({ kind: "selection", label: `来自选股 ${sel.as_of_date}(${sel.method})`, note: "已把该次选股的**规则**(条件/因子/TopN/口径)预填为回测参数。" + "注意:回测会在每个择股日按同一规则重新选股,而不是固定持有该次选出的这批股票。", }); return; } catch (e) { setError(`选股结果 ${selectionId} 载入失败:${(e as Error).message}`); } } // 3) 实验参数复用 if (experimentId) { try { const exp = await apiGet<{ result?: BacktestResult & { config_snapshot?: Record } }>( `/experiments/${encodeURIComponent(experimentId)}` ); const snap = exp.result?.config_snapshot as Partial | undefined; if (snap) { setParams(paramsFromSpec(snap, base)); setLoaded({ kind: "experiment", label: `来自实验 ${experimentId}`, note: exp.result?.equity_curve?.length ? `已复刻该实验参数,并直接展示它的归档结果(未重跑)。归档快照请到 /experiments/${experimentId} 查看;改参数后可再跑一次并与它对比。` : "已复刻该实验的参数,可改后重跑并与原实验对比。", }); // 实验已归档完整结果:直接展示,省一次几分钟的重跑 if (exp.result?.equity_curve?.length) { setResult(exp.result); setLastExp(experimentId); setArchiveId(experimentId); // 提示条里给「打开归档」入口 } return; } setError(`实验 ${experimentId} 没有可复用的参数快照`); } catch (e) { const msg = (e as Error).message; setError(`实验 ${experimentId} 载入失败:${msg}`); // 归档可能已被删除:清掉 localStorage 里的指针,避免「载入上次结果」每次都给坏链接。 // 只清指针(不影响参数区),并且只有在确实是 404 时才清。 if (msg.includes("404")) { try { if (window.localStorage.getItem("qlib:last-backtest-experiment") === experimentId) { window.localStorage.removeItem("qlib:last-backtest-experiment"); setLastExp(null); } } catch { /* localStorage 不可用:无需清理 */ } } } } setParams(base); })(); // 仅首次进入时按 URL 载入 // eslint-disable-next-line react-hooks/exhaustive-deps }, []); const errors = params ? validateParams(params, { requireMeta: params.name.trim() !== "" }) : {}; const blocking = ["topN", "holdX", "factors", "mMonths", "yMonths", "costs", "capital", "period", "conditions"].filter( (k) => errors[k] ); const doc = useStrategyDoc(params); async function run() { if (!params) return; if (blocking.length) { setError(errors[blocking[0]]); return; } setRunning(true); setError(""); setNotice(""); setJobId(""); setArchiveId(""); setResult(null); try { const spec = { type: "backtest" as const, universe: { exclude_st: params.excludeSt, min_listing_days: params.minListingDays }, price_adjustment: params.priceAdjustment, factors: params.factors.filter((f) => f.name.trim() !== ""), conditions: params.conditions.filter((c) => c.field.trim() !== ""), selection: { top_n: params.topN, hold_top_x: params.holdX, allow_substitute: params.fillPolicy === "substitute", defer_buy: params.fillPolicy === "defer", }, rebalance: params.rebalance, selection_interval_months: params.mMonths > 0 ? params.mMonths : params.yMonths > 0 ? params.yMonths : null, rebalance_interval_months: params.yMonths > 0 ? params.yMonths : params.mMonths > 0 ? params.mMonths : null, costs: { commission_rate: params.commission / 100, stamp_tax_rate: params.stamp / 100, slippage_rate: params.slippage / 100, min_commission: params.minCommission, }, initial_capital: params.capital, period: [params.start, params.end] as [string, string], }; const { job_id } = await submitJob(spec); setJobId(job_id); setStage("queued"); const out = await waitJob(job_id, 900_000, (info) => { setStage(info.stage); setElapsed(info.elapsedMs); }); if (out.status === "success" && out.result) { setResult(out.result); const exp = out.experimentId ?? null; if (exp) { setLastExp(exp); setArchiveId(exp); try { window.localStorage.setItem("qlib:last-backtest-experiment", exp); } catch { /* 忽略:仅影响下次进入的便捷入口 */ } } setNotice( exp ? `回测完成,已归档为实验 ${exp}(可在「实验对比」页与其它版本对比)。` : "回测完成,已自动归档到「实验对比」页。" ); } else { setError(`任务${out.status}${out.error ? `:${out.error}` : ""}`); } } catch (e) { setError((e as Error).message); } finally { setRunning(false); setJobId(""); } } /** 载入上次回测结果:避免刷新页面后必须重跑一个几分钟的作业 */ async function loadLast() { if (!lastExp) return; setRestoring(true); setError(""); try { const exp = await apiGet<{ result?: BacktestResult }>(`/experiments/${encodeURIComponent(lastExp)}`); if (exp.result?.equity_curve?.length) { setResult(exp.result); setArchiveId(lastExp); setNotice(`已载入上次归档结果(${lastExp}),未重新执行作业。`); } else { setError(`实验 ${lastExp} 没有可载入的回测结果`); } } catch (e) { setError((e as Error).message); } finally { setRestoring(false); } } async function saveAsStrategy() { if (!params) return; const need = validateParams(params, { requireMeta: true }); if (Object.keys(need).length) { setError(Object.values(need)[0]); return; } setSaving(true); setError(""); try { const saved = await apiPost("/strategies", strategyFromParams(params)); setNotice(`已保存到策略库:「${saved.name}」(${saved.id})—— 可在策略库一键回测/编辑。`); setLoaded({ kind: "strategy", label: `已保存为策略 ${saved.name}(${saved.id})` }); } catch (e) { setError((e as Error).message); } finally { setSaving(false); } } return ( <> 策略库 实验对比 } /> {lastExp && !result && !running ? (
上次结果 本机上次回测已归档为实验 {lastExp}; 直接载入可省去重跑(全市场 8 年区间约 3~5 分钟)。 载入上次结果 { setLastExp(null); try { window.localStorage.removeItem("qlib:last-backtest-experiment"); } catch { /* 忽略 */ } }} > 忽略
) : null} {loaded.kind !== "none" ? ( {loaded.label} {loaded.note ? ` · ${loaded.note}` : ""} ) : null} {error ? {error} : null} {notice ? ( {notice} {archiveId ? ( <> {" "} 打开归档(完整快照) ) : null} ) : null} params && setParams({ ...casePreset(range), name: params.name, description: params.description })}> 载入高股息案例默认参数 保存为策略 } > {params ? ( <>
0} onClick={run}> {running ? "后台运行中…" : "运行回测"} n={params.topN} → x={params.holdX} · m={params.mMonths} / y={params.yMonths} ·{" "} {adjustLabel(params.priceAdjustment)} · {params.start} ~ {params.end} {blocking.length ? ( 有 {blocking.length} 处参数需要修正 ) : ( 参数校验通过 )}
) : ( )} {running ? (
{["data_loading", "selection", "backtesting", "analysis"].map((st) => { const order = ["queued", "data_loading", "selection", "backtesting", "analysis", "done"]; const cur = order.indexOf(stage); const mine = order.indexOf(st); const cls = mine < cur ? "stage is-done" : mine === cur ? "stage is-active" : "stage"; return ( {mine < cur ? "✓" : mine === cur ? "●" : "○"} {STAGE_LABEL[st] ?? st} ); })} 作业 {jobId || "排队中…"} · 已用{" "} {Math.round(elapsed / 1000)}s(全市场 8 年区间约 3~5 分钟,可离开本页,结果会归档)
) : null}
{!result && !running && !error ? ( ) : null} {result ? : null} ); }