汇总三轮未提交的开发(每轮均在本机 MariaDB + 真实浏览器上验证):
1) 股息率案例(全市场股息率最高 n 只,默认 20,每 m 月择股)
- 新增日频估值表 daily_basic + 迁移;股息率因子(dv_ratio / dividend_yield / TTM)
- 名称历史表 stock_name_history:剔除 ST 按**择股日当时名称**判定,消除
「曾高股息后 ST」的股息陷阱(实测 3.70pp 偏差)
- 区间择股/调仓双周期(m 择股 / y 调仓)、指数成分与白名单、停牌近似剔除
- 复权因子口径核对(4,164,742 行、缺失 0.0%)、收盘价成交与涨跌停拦单
- 案例实测:2020-01-01~2026-09-04 总收益 +24.86%(年化 3.52%、回撤 -28.58%)
2) 策略库与前端统一
- strategy 表 + CRUD/PUT 原地更新 + `describe_strategy` 按 spec 真实推导
「一句话说明 + 计算公式 + 执行步骤 + 注意事项」(与引擎实执行规则同源)
- 任何出现股票代码处都成对显示名称且可点击进个股页
- 全站图表基座统一 TradingView Lightweight Charts(ECharts 依赖、
锁文件、组件与文档标注一并清除),买卖点标记只落在真实交易日上
3) 回测存档完整化(可往复查看)
- 同步端点(POST /api/backtests、/api/factor-tests)此前完全不落库 → 现在同样归档,
归档 id 经响应头 X-Experiment-Id 返回(不破坏 response_model)
- data_version 首次真实写入(数据快照指纹:最新交易日 + 各表规模)
- 个股收益曲线默认**全量保存**(此前硬截断 60 只);超出体积预算才裁剪,
并写 archive_meta(机器可读)+ unimplemented(人可读)如实标注
- 列表 kind/q 过滤 + X-Total-Count(此前 limit=50 静默截断)、DELETE 归档
- 只读归档页 /experiments/{id}(Server Component,SSR 直出**选股条件**与
**交易执行依据**);结果视图按 kind 分发(backtest/factor_test/selection),
非回测归档不套用回测口径
- 新增 CLI:prune_experiments(保留策略,默认 dry-run)、
restore_experiment_from_job(从 Job 副本按原 id 重建被删的历史归档,默认 dry-run)
门禁:pytest 388 passed、ruff All checks passed、tsc 0 错误、图表单测 7 passed、
next build 成功、契约脚本 verify_strategy_workspace 59/59(含按 kind 逐类验证归档页)。
487 lines
18 KiB
TypeScript
487 lines
18 KiB
TypeScript
"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 (
|
||
<Suspense fallback={<Loading label="加载回测页…" />}>
|
||
<BacktestInner />
|
||
</Suspense>
|
||
);
|
||
}
|
||
|
||
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<StrategyParams | null>(null);
|
||
const [factors, setFactors] = useState<FactorMeta[]>([]);
|
||
const [loaded, setLoaded] = useState<LoadState>({ kind: "none", label: "" });
|
||
const [result, setResult] = useState<BacktestResult | null>(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<string | null>(null);
|
||
const [archiveId, setArchiveId] = useState("");
|
||
const [restoring, setRestoring] = useState(false);
|
||
const bootstrapped = useRef(false);
|
||
|
||
/* ---------- 初始化:因子表 + 默认/来源参数 ---------- */
|
||
useEffect(() => {
|
||
let alive = true;
|
||
apiGet<FactorMeta[]>("/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<StrategyDefinition>(`/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<SelectionResult>(`/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<string, unknown> } }>(
|
||
`/experiments/${encodeURIComponent(experimentId)}`
|
||
);
|
||
const snap = exp.result?.config_snapshot as Partial<ResearchSpec> | 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<BacktestResult>(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<StrategyDefinition>("/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 (
|
||
<>
|
||
<PageHeader
|
||
title="选股回测"
|
||
sub="两级截断择股(候选池 n → 持仓 x)+ 择股/调仓双周期(m/y),收盘价撮合,买卖点与个股收益曲线可视化,结果自动归档可复现。"
|
||
actions={
|
||
<div className="row" style={{ gap: 8 }}>
|
||
<Link href="/strategies" className="btn">
|
||
<span>策略库</span>
|
||
</Link>
|
||
<Link href="/experiments" className="btn">
|
||
<span>实验对比</span>
|
||
</Link>
|
||
</div>
|
||
}
|
||
/>
|
||
|
||
{lastExp && !result && !running ? (
|
||
<div className="sticky-bar" style={{ position: "static" }}>
|
||
<Pill tone="accent" icon="archive">
|
||
上次结果
|
||
</Pill>
|
||
<span className="hint">
|
||
本机上次回测已归档为实验 <span className="mono">{lastExp}</span>;
|
||
直接载入可省去重跑(全市场 8 年区间约 3~5 分钟)。
|
||
</span>
|
||
<Btn size="sm" icon="refresh" loading={restoring} disabled={restoring} onClick={loadLast}>
|
||
载入上次结果
|
||
</Btn>
|
||
<Btn
|
||
size="sm"
|
||
icon="x"
|
||
onClick={() => {
|
||
setLastExp(null);
|
||
try {
|
||
window.localStorage.removeItem("qlib:last-backtest-experiment");
|
||
} catch {
|
||
/* 忽略 */
|
||
}
|
||
}}
|
||
>
|
||
忽略
|
||
</Btn>
|
||
</div>
|
||
) : null}
|
||
|
||
{loaded.kind !== "none" ? (
|
||
<Banner tone="info">
|
||
{loaded.label}
|
||
{loaded.note ? ` · ${loaded.note}` : ""}
|
||
</Banner>
|
||
) : null}
|
||
{error ? <Banner tone="error">{error}</Banner> : null}
|
||
{notice ? (
|
||
<Banner tone="info">
|
||
{notice}
|
||
{archiveId ? (
|
||
<>
|
||
{" "}
|
||
<Link href={`/experiments/${encodeURIComponent(archiveId)}`} className="btn btn--sm">
|
||
<span>打开归档(完整快照)</span>
|
||
</Link>
|
||
</>
|
||
) : null}
|
||
</Banner>
|
||
) : null}
|
||
|
||
<Card
|
||
icon="gauge"
|
||
title="研究参数(Research Specification)"
|
||
tools={
|
||
<div className="row" style={{ gap: 8 }}>
|
||
<Btn icon="target" disabled={running || !params} onClick={() => params && setParams({ ...casePreset(range), name: params.name, description: params.description })}>
|
||
载入高股息案例默认参数
|
||
</Btn>
|
||
<Btn icon="book" loading={saving} disabled={running || saving || !params} onClick={saveAsStrategy}>
|
||
保存为策略
|
||
</Btn>
|
||
</div>
|
||
}
|
||
>
|
||
{params ? (
|
||
<>
|
||
<StrategyParamsForm
|
||
value={params}
|
||
onChange={setParams}
|
||
factorOptions={factors}
|
||
showMeta
|
||
showPeriod
|
||
disabled={running}
|
||
errors={errors}
|
||
/>
|
||
<div className="sticky-bar" style={{ marginTop: 14, position: "static" }}>
|
||
<Btn variant="primary" icon="play" loading={running} disabled={running || blocking.length > 0} onClick={run}>
|
||
{running ? "后台运行中…" : "运行回测"}
|
||
</Btn>
|
||
<span className="hint">
|
||
n={params.topN} → x={params.holdX} · m={params.mMonths} / y={params.yMonths} ·{" "}
|
||
{adjustLabel(params.priceAdjustment)} · {params.start} ~ {params.end}
|
||
</span>
|
||
{blocking.length ? (
|
||
<Pill tone="warn" icon="alert">
|
||
有 {blocking.length} 处参数需要修正
|
||
</Pill>
|
||
) : (
|
||
<Pill tone="pos" icon="check">
|
||
参数校验通过
|
||
</Pill>
|
||
)}
|
||
</div>
|
||
</>
|
||
) : (
|
||
<Loading label="准备参数…" />
|
||
)}
|
||
|
||
{running ? (
|
||
<div className="stages" style={{ marginTop: 12 }}>
|
||
{["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 (
|
||
<span className={cls} key={st}>
|
||
{mine < cur ? "✓" : mine === cur ? "●" : "○"} {STAGE_LABEL[st] ?? st}
|
||
</span>
|
||
);
|
||
})}
|
||
<span className="hint">
|
||
作业 <span className="mono">{jobId || "排队中…"}</span> · 已用{" "}
|
||
{Math.round(elapsed / 1000)}s(全市场 8 年区间约 3~5 分钟,可离开本页,结果会归档)
|
||
</span>
|
||
</div>
|
||
) : null}
|
||
</Card>
|
||
|
||
<StrategyDocCard
|
||
doc={doc.doc}
|
||
loading={doc.loading}
|
||
error={doc.error}
|
||
title="策略说明与计算公式(随参数实时更新)"
|
||
/>
|
||
|
||
{!result && !running && !error ? (
|
||
<Card>
|
||
<Empty
|
||
icon="gauge"
|
||
title="尚未运行回测"
|
||
hint="可点「载入高股息案例默认参数」一键填充;调好后点「保存为策略」即可在策略库复用、对比。"
|
||
/>
|
||
</Card>
|
||
) : null}
|
||
|
||
{result ? <BacktestResultView result={result} name={params?.name ?? ""} /> : null}
|
||
</>
|
||
);
|
||
}
|