feat: 股息率案例口径 + 策略库与图表统一 + 回测存档完整化
汇总三轮未提交的开发(每轮均在本机 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 逐类验证归档页)。
This commit is contained in:
+414
-250
@@ -1,88 +1,280 @@
|
||||
"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";
|
||||
/**
|
||||
* 选股回测页 —— 研究闭环里的「定规则 + 验证」环节。
|
||||
*
|
||||
* 本页承担三种进入方式(都可分享 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 {
|
||||
PageHeader,
|
||||
Card,
|
||||
Pill,
|
||||
Field,
|
||||
Btn,
|
||||
Banner,
|
||||
Progress,
|
||||
Empty,
|
||||
BacktestMetrics,
|
||||
MonthlyReturnsTable,
|
||||
UnimplementedNote,
|
||||
} from "@/components/ui";
|
||||
import { LineChart } from "@/components/LineChart";
|
||||
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 [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 [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) => {
|
||||
if (!alive) return;
|
||||
setFactors(list);
|
||||
if (list.length > 0 && !list.some((f) => f.name === factor)) {
|
||||
setFactor(list[0].name);
|
||||
}
|
||||
})
|
||||
.then((list) => alive && setFactors(list))
|
||||
.catch((e: Error) => alive && setError(e.message));
|
||||
const { start: s, end: e } = recentRange();
|
||||
setStart(s);
|
||||
setEnd(e);
|
||||
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 (!factor) return;
|
||||
if (!params) return;
|
||||
if (blocking.length) {
|
||||
setError(errors[blocking[0]]);
|
||||
return;
|
||||
}
|
||||
setRunning(true);
|
||||
setError("");
|
||||
setNotice("");
|
||||
setJobId("");
|
||||
setArchiveId("");
|
||||
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,
|
||||
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",
|
||||
},
|
||||
initial_capital: capital,
|
||||
period: [start, end],
|
||||
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],
|
||||
};
|
||||
// 异步 Job:后台执行(全市场可能数十秒),轮询到终态
|
||||
const { job_id } = await submitJob(spec);
|
||||
setJobId(job_id);
|
||||
const out = await waitJob<BacktestResult>(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}` : ""}`);
|
||||
}
|
||||
@@ -94,229 +286,201 @@ export default function BacktestPage() {
|
||||
}
|
||||
}
|
||||
|
||||
/** 载入上次回测结果:避免刷新页面后必须重跑一个几分钟的作业 */
|
||||
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="以单个因子的横截面排序构建 Top N 组合并完整回测:净值 / 回撤 / 月度收益 / 持仓明细。"
|
||||
sub="两级截断择股(候选池 n → 持仓 x)+ 择股/调仓双周期(m/y),收盘价撮合,买卖点与个股收益曲线可视化,结果自动归档可复现。"
|
||||
actions={
|
||||
result ? (
|
||||
<Pill tone="pos" icon="check">
|
||||
最近一次回测已完成
|
||||
</Pill>
|
||||
) : null
|
||||
<div className="row" style={{ gap: 8 }}>
|
||||
<Link href="/strategies" className="btn">
|
||||
<span>策略库</span>
|
||||
</Link>
|
||||
<Link href="/experiments" className="btn">
|
||||
<span>实验对比</span>
|
||||
</Link>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
<Card title="研究参数(Research Specification)" icon="gauge">
|
||||
<div className="form-grid">
|
||||
<Field label="因子">
|
||||
<select
|
||||
className="input"
|
||||
value={factor}
|
||||
onChange={(e) => setFactor(e.target.value)}
|
||||
>
|
||||
{factors.map((f) => (
|
||||
<option key={f.name} value={f.name}>
|
||||
{f.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
<Field label="Top N">
|
||||
<input
|
||||
type="number"
|
||||
className="input"
|
||||
min={1}
|
||||
max={50}
|
||||
value={topN}
|
||||
onChange={(e) => setTopN(Number(e.target.value))}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="调仓频率">
|
||||
<select
|
||||
className="input"
|
||||
value={rebalance}
|
||||
onChange={(e) => setRebalance(e.target.value as "monthly" | "weekly")}
|
||||
>
|
||||
<option value="monthly">月度</option>
|
||||
<option value="weekly">周度</option>
|
||||
</select>
|
||||
</Field>
|
||||
<Field label="手续费率 %">
|
||||
<input className="input" type="number" step="0.01" min={0} value={commission}
|
||||
onChange={(e) => setCommission(Number(e.target.value))} />
|
||||
</Field>
|
||||
<Field label="印花税率 %">
|
||||
<input className="input" type="number" step="0.01" min={0} value={stamp}
|
||||
onChange={(e) => setStamp(Number(e.target.value))} />
|
||||
</Field>
|
||||
<Field label="滑点率 %">
|
||||
<input className="input" type="number" step="0.01" min={0} value={slippage}
|
||||
onChange={(e) => setSlippage(Number(e.target.value))} />
|
||||
</Field>
|
||||
<Field label="初始资金(元)">
|
||||
<input className="input" type="number" step={100000} min={10000} value={capital}
|
||||
onChange={(e) => setCapital(Number(e.target.value))} />
|
||||
</Field>
|
||||
<Field label="开始日期">
|
||||
<input type="date" className="input" value={start} onChange={(e) => setStart(e.target.value)} />
|
||||
</Field>
|
||||
<Field label="结束日期">
|
||||
<input type="date" className="input" value={end} onChange={(e) => setEnd(e.target.value)} />
|
||||
</Field>
|
||||
<Field label="标的范围">
|
||||
<label className="row" style={{ gap: 6, color: "var(--text-2)", fontSize: 13, cursor: "pointer" }}>
|
||||
<input type="checkbox" checked={excludeSt} onChange={(e) => setExcludeSt(e.target.checked)} />
|
||||
剔除 ST
|
||||
</label>
|
||||
</Field>
|
||||
<Btn variant="primary" icon="play" loading={running} disabled={running} onClick={run}>
|
||||
{running ? "后台运行中…" : "运行回测"}
|
||||
{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 style={{ marginTop: 14 }}>
|
||||
<Progress
|
||||
value={35}
|
||||
label={
|
||||
<>
|
||||
任务 <span className="mono">{jobId || "排队中…"}</span> 后台执行中,请稍候…
|
||||
</>
|
||||
}
|
||||
/>
|
||||
<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}
|
||||
{error ? <div style={{ marginTop: 12 }}><Banner tone="error">{error}</Banner></div> : null}
|
||||
</Card>
|
||||
|
||||
<StrategyDocCard
|
||||
doc={doc.doc}
|
||||
loading={doc.loading}
|
||||
error={doc.error}
|
||||
title="策略说明与计算公式(随参数实时更新)"
|
||||
/>
|
||||
|
||||
{!result && !running && !error ? (
|
||||
<Card>
|
||||
<Empty
|
||||
icon="gauge"
|
||||
title="尚未运行回测"
|
||||
hint="设置上方参数后运行;完成展示净值曲线、回撤、月度收益与最新持仓。"
|
||||
hint="可点「载入高股息案例默认参数」一键填充;调好后点「保存为策略」即可在策略库复用、对比。"
|
||||
/>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
{result ? <ResultView result={result} /> : null}
|
||||
{result ? <BacktestResultView result={result} name={params?.name ?? ""} /> : 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 (
|
||||
<>
|
||||
<div className="between" style={{ margin: "6px 0 14px" }}>
|
||||
<div className="row">
|
||||
<Pill tone="pos" icon="check">完成</Pill>
|
||||
<Pill tone="violet" icon="target">Top N 等权</Pill>
|
||||
<Pill>{s.start} ~ {s.end}</Pill>
|
||||
</div>
|
||||
<div className="row" style={{ fontSize: 22, fontWeight: 700 }}>
|
||||
总收益 <SignedText value={s.total_return_pct} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<BacktestMetrics s={s} />
|
||||
|
||||
<div className="chart-grid">
|
||||
<Card icon="chartLine" title="净值曲线" tools={<Pill tone="pos">期末 {s.final_equity.toLocaleString()}</Pill>}>
|
||||
<LineChart data={result.equity_curve} color="#3ddc97" fill height={300} xFmt={(d) => d.slice(5)} yFmt={(v) => v.toFixed(2)} />
|
||||
</Card>
|
||||
<Card icon="chartLine" title="回撤(%)" tools={<Pill tone="neg">最大 {s.max_drawdown_pct.toFixed(2)}%</Pill>}>
|
||||
<LineChart data={result.drawdown} color="#ff7a7a" height={300} xFmt={(d) => d.slice(5)} yFmt={(v) => v.toFixed(2) + "%"} />
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Card icon="calendar" title="月度收益(%)">
|
||||
<MonthlyReturnsTable rows={result.monthly_returns} />
|
||||
</Card>
|
||||
|
||||
<Card icon="target" title={`最新持仓 · ${last?.date ?? "—"}`}>
|
||||
{holdings.length === 0 ? (
|
||||
<Empty title="无持仓记录" hint="回测区间内没有产生持仓。" />
|
||||
) : (
|
||||
<div className="chips">
|
||||
{holdings.map((p) => (
|
||||
<span className="chip" key={p.symbol}>
|
||||
<b className="mono">{p.symbol}</b>
|
||||
{(p.weight * 100).toFixed(0)}%
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{result.yearly_returns.length ? (
|
||||
<Card icon="calendar" title="年度收益(%)" tools={<Pill>均值 {(result.yearly_returns.reduce((a, y) => a + y.return_pct, 0) / result.yearly_returns.length).toFixed(2)}%</Pill>}>
|
||||
<div className="chips">
|
||||
{result.yearly_returns.map((y) => (
|
||||
<span className="chip" key={y.year}>
|
||||
<b>{y.year}</b>
|
||||
<span className={y.return_pct >= 0 ? "tone-pos" : "tone-neg"}>
|
||||
{y.return_pct.toFixed(2)}%
|
||||
</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
{result.trades.length ? (
|
||||
<Card
|
||||
icon="scale"
|
||||
title={`成交明细 · ${result.trades.length} 笔`}
|
||||
tools={<Pill>{result.turnover_pct.toFixed(2)}% 累计换手</Pill>}
|
||||
>
|
||||
<div className="table-wrap">
|
||||
<table className="tbl">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>买入日</th>
|
||||
<th>卖出日</th>
|
||||
<th>代码</th>
|
||||
<th>买价</th>
|
||||
<th>卖价</th>
|
||||
<th>收益</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{result.trades.map((t) => (
|
||||
<tr key={`${t.symbol}-${t.entry_date}-${t.exit_date}`}>
|
||||
<td className="mono dim">{t.entry_date}</td>
|
||||
<td className="mono dim">{t.exit_date}</td>
|
||||
<td className="mono"><b>{t.symbol}</b></td>
|
||||
<td className="mono">{t.entry_price?.toFixed(2) ?? "-"}</td>
|
||||
<td className="mono">{t.exit_price?.toFixed(2) ?? "-"}</td>
|
||||
<td className={t.return_pct >= 0 ? "tone-pos" : "tone-neg"}>
|
||||
{t.return_pct.toFixed(2)}%
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
<UnimplementedNote items={result.unimplemented} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function SignedText({ value }: { value: number }) {
|
||||
const cls = value >= 0 ? "tone-pos" : "tone-neg";
|
||||
return (
|
||||
<span className={cls}>
|
||||
{value > 0 ? "+" : ""}
|
||||
{value.toFixed(2)}%
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user