feat(web): 字段库页 + 界面单位换算 + 因子目录参数化 + 选股策略条件表单
字段库与单位: - `/fields`:字段库管理页(中文名/说明可改、可停用;kind 是引擎事实不可改); 单位只在字段自己的阶梯里选(总市值 = 万元/亿元),越界 422 原样展示。 - `lib/units.ts`:界面单位 ⇄ 基准单位换算集中一处,条件输入按界面单位回显、 提交前换回基准单位(引擎只认基准单位,库里存的也永远是基准单位)。 - `SelectionStrategyForm` 取代 `StrategyParamsForm`:一个策略只定义「怎么选」 (股票池 + 因子 + 过滤条件),条件字段来自字段库接口而不是前端硬编码枚举。 因子参数化(名字即身份,界面不许藏): - `/factors` 新增「参数」列与展开行:精确引擎键、每个参数的允许范围、来源、依赖列 (依赖列标明「引擎事实,不可改」);说明文案从「不可修改」改为 「改参数 = 新建参数化因子 = 新身份,旧因子/既有策略不变义」。 - 「新建参数化因子」卡片:模板下拉 + 受控窗口(min/max)+ 方向枚举下拉, 实时预览规范键/中文名/渲染公式;后端 422 的原文原样展示,不静默截断。 - 因子下拉显示中文名(含参数)与短键,**value 一律是完整引擎键**; 停用的因子从各页候选里消失(/strategies、/selection、/signals、/factors/compose), 既有策略/归档仍按名字解析;`/factors/compose` 顺手修了勾选框点击目标过小。 - `/fields` 加提示:字段库只列内置因子名,参数化因子在 /factors 管理, 选股策略条件下拉里会一并出现。 tsc --noEmit 0 error;verify_ui_alignment 8 页 160 项全过(另跑 3 个选因子页 51/54)。
This commit is contained in:
+496
-337
@@ -1,266 +1,246 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* 选股回测页 —— 研究闭环里的「定规则 + 验证」环节。
|
||||
* 回测组合(/backtest)—— 选若干选股策略 + 填回测参数 → 保存为组合 / 直接运行。
|
||||
*
|
||||
* 本页承担三种进入方式(都可分享 URL):
|
||||
* - 直接进入:从默认参数开始调;
|
||||
* - `?strategy=STG-xxx`:从策略库载入一个已保存策略(可微调、可另存);
|
||||
* - `?from_selection=SEL-xxx`:从选股结果直通过来,预填当时的条件/因子/TopN
|
||||
* (解决「选出来的股票要手工抄到回测页」的断点);
|
||||
* - `?from_experiment=EXP-xxx`:从实验详情「以此参数再跑一次」进来。
|
||||
* 2026-09 重构后这里是回测的**唯一产品入口**:
|
||||
* - 选股策略(怎么选股)来自 /strategies;
|
||||
* - 费率 / 复权口径来自 /settings(公共配置,本页只读展示);
|
||||
* - 本页只定「回测时才定的参数」:起始资金、持仓数 N、持仓天数区间 [Tmin,Tmax]、
|
||||
* 调仓时机(日/周/月)、回测区间。
|
||||
*
|
||||
* 交互改进:参数区与说明/公式同屏(说明随参数实时更新,由后端推导);
|
||||
* 「保存为策略」把当前参数存进策略库;结果区所有股票代码都带名称且可点进个股页。
|
||||
* 进入方式(都可分享 URL):
|
||||
* - 直接进入:空组合开始;
|
||||
* - `?combo=CMB-xxx`:载入已保存组合;
|
||||
* - `?strategy=STG-xxx`:预先把该选股策略勾进组合(从策略库「加入回测组合」过来)。
|
||||
*/
|
||||
import { Suspense, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Suspense, useCallback, 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 { apiDelete, apiGet, apiPost, apiPut } from "@/lib/api";
|
||||
import { STAGE_LABEL, runSavedCombo, submitComboJob, 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 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 { 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="加载回测页…" />}>
|
||||
<Suspense fallback={<Loading label="加载回测组合页…" />}>
|
||||
<BacktestInner />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
function draftToCombo(d: Draft): BacktestCombo {
|
||||
return {
|
||||
name: d.name.trim(),
|
||||
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<Draft["rebalanceFreq"], string> = {
|
||||
daily: "每日",
|
||||
weekly: "每周",
|
||||
monthly: "每月",
|
||||
};
|
||||
|
||||
function validateDraft(d: Draft, strategies: SelectionStrategy[]): Record<string, string> {
|
||||
const e: Record<string, string> = {};
|
||||
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 strategyId = search.get("strategy");
|
||||
const selectionId = search.get("from_selection");
|
||||
const experimentId = search.get("from_experiment");
|
||||
const comboId = search.get("combo");
|
||||
const presetStrategy = search.get("strategy");
|
||||
|
||||
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 [strategies, setStrategies] = useState<SelectionStrategy[]>([]);
|
||||
const [config, setConfig] = useState<GlobalConfig | null>(null);
|
||||
const [combos, setCombos] = useState<BacktestCombo[] | null>(null);
|
||||
const [draft, setDraft] = useState<Draft>(emptyDraft(range));
|
||||
const [savedComboId, setSavedComboId] = useState<string | null>(null);
|
||||
const [result, setResult] = useState<BacktestResult | null>(null);
|
||||
const [running, setRunning] = useState(false);
|
||||
const [runningComboId, setRunningComboId] = useState<string | null>(null);
|
||||
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 [deletingComboId, setDeletingComboId] = useState<string | null>(null);
|
||||
const [revealErrors, setRevealErrors] = useState(false);
|
||||
const bootstrapped = useRef(false);
|
||||
|
||||
/* ---------- 初始化:因子表 + 默认/来源参数 ---------- */
|
||||
/* ---------- 初始化:策略列表 + 公共配置 + 已保存组合 + 来源载入 ---------- */
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
apiGet<FactorMeta[]>("/factors")
|
||||
.then((list) => alive && setFactors(list))
|
||||
Promise.all([
|
||||
apiGet<SelectionStrategy[]>("/strategies"),
|
||||
apiGet<GlobalConfig>("/config"),
|
||||
])
|
||||
.then(([sts, cfg]) => {
|
||||
if (!alive) return;
|
||||
setStrategies(sts);
|
||||
setConfig(cfg);
|
||||
})
|
||||
.catch((e: Error) => alive && setError(e.message));
|
||||
// 组合库单独拉取:即便它失败,也不该连累策略列表与公共配置(否则整页只剩一条错误)。
|
||||
apiGet<BacktestCombo[]>("/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<BacktestCombo[]>("/combos"));
|
||||
} catch (e) {
|
||||
setError((e as Error).message);
|
||||
setCombos([]);
|
||||
}
|
||||
}, []);
|
||||
|
||||
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) {
|
||||
if (comboId) {
|
||||
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: "参数已载入;改完可「另存为新策略」或直接运行回测。",
|
||||
});
|
||||
const c = await apiGet<BacktestCombo>(`/combos/${encodeURIComponent(comboId)}`);
|
||||
setDraft(comboToDraft(c));
|
||||
setSavedComboId(c.id ?? null);
|
||||
setNotice(`已载入回测组合「${c.name}」(${c.id})`);
|
||||
return;
|
||||
} catch (e) {
|
||||
setError(`策略 ${strategyId} 载入失败:${(e as Error).message}`);
|
||||
setError(`回测组合 ${comboId} 载入失败:${(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}`);
|
||||
}
|
||||
if (presetStrategy) {
|
||||
setDraft((d) => ({ ...d, strategyIds: [presetStrategy] }));
|
||||
setNotice(`已把选股策略 ${presetStrategy} 预勾选进组合 —— 可再加其它策略、填好参数后运行。`);
|
||||
}
|
||||
// 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 [revealErrors, setRevealErrors] = useState(false);
|
||||
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);
|
||||
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 (!params) return;
|
||||
if (blocking.length) {
|
||||
setError(errors[blocking[0]]);
|
||||
setRevealErrors(true);
|
||||
// 焦点管理:直接把人带到出问题的控件上,而不是只丢一句提示让人自己找
|
||||
requestAnimationFrame(() => {
|
||||
const el = document.querySelector<HTMLElement>(".input--invalid, .field--invalid input");
|
||||
el?.focus();
|
||||
const el = document.querySelector<HTMLElement>(".input--invalid, .field--invalid");
|
||||
el?.scrollIntoView({ block: "center", behavior: "smooth" });
|
||||
});
|
||||
return;
|
||||
}
|
||||
await runVia(() => submitComboJob(draftToCombo(draft)), null);
|
||||
}
|
||||
|
||||
/**
|
||||
* 共用的「提交 Job → 轮询 → 出结果」管道。
|
||||
*
|
||||
* 两条入口共用:① 用页面上的草稿运行(临时组合,不落库);② 从组合库直接运行已保存组合
|
||||
* (走 POST /combos/{id}/run,跑库里那份参数)。抽出来是为了让两条路径的阶段显示、
|
||||
* 归档提示、错误处理完全一致,不会一条修了另一条忘了。
|
||||
*/
|
||||
async function runVia(submit: () => Promise<{ job_id: string }>, comboId: string | null) {
|
||||
setRunning(true);
|
||||
setRunningComboId(comboId);
|
||||
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);
|
||||
const { job_id } = await submit();
|
||||
setJobId(job_id);
|
||||
setStage("queued");
|
||||
const out = await waitJob<BacktestResult>(job_id, 900_000, (info) => {
|
||||
@@ -270,20 +250,7 @@ function BacktestInner() {
|
||||
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}(可在「实验对比」页与其它版本对比)。`
|
||||
: "回测完成,已自动归档到「实验对比」页。"
|
||||
);
|
||||
setNotice(exp ? `回测完成,已归档为实验 ${exp}(可在「实验对比」页与其它版本对比)。` : "回测完成,已自动归档。");
|
||||
} else {
|
||||
setError(`任务${out.status}${out.error ? `:${out.error}` : ""}`);
|
||||
}
|
||||
@@ -291,44 +258,83 @@ function BacktestInner() {
|
||||
setError((e as Error).message);
|
||||
} finally {
|
||||
setRunning(false);
|
||||
setRunningComboId(null);
|
||||
setJobId("");
|
||||
}
|
||||
}
|
||||
|
||||
/** 载入上次回测结果:避免刷新页面后必须重跑一个几分钟的作业 */
|
||||
async function loadLast() {
|
||||
if (!lastExp) return;
|
||||
setRestoring(true);
|
||||
/* ---------- 组合库操作 ---------- */
|
||||
|
||||
/** 把库里的一份组合回填到表单(不自动运行,先把参数摆出来给用户看/改)。 */
|
||||
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 {
|
||||
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} 没有可载入的回测结果`);
|
||||
}
|
||||
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 {
|
||||
setRestoring(false);
|
||||
setDeletingComboId(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function saveAsStrategy() {
|
||||
if (!params) return;
|
||||
const need = validateParams(params, { requireMeta: true });
|
||||
if (Object.keys(need).length) {
|
||||
setError(Object.values(need)[0]);
|
||||
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 saved = await apiPost<StrategyDefinition>("/strategies", strategyFromParams(params));
|
||||
setNotice(`已保存到策略库:「${saved.name}」(${saved.id})—— 可在策略库一键回测/编辑。`);
|
||||
setLoaded({ kind: "strategy", label: `已保存为策略 ${saved.name}(${saved.id})` });
|
||||
const combo = draftToCombo(draft);
|
||||
if (savedComboId) {
|
||||
await apiPut<BacktestCombo>(`/combos/${encodeURIComponent(savedComboId)}`, combo);
|
||||
setNotice(`已更新回测组合「${combo.name}」`);
|
||||
} else {
|
||||
const saved = await apiPost<BacktestCombo>("/combos", combo);
|
||||
setSavedComboId(saved.id ?? null);
|
||||
setNotice(`已保存回测组合「${saved.name}」(${saved.id})`);
|
||||
}
|
||||
await loadCombos();
|
||||
} catch (e) {
|
||||
setError((e as Error).message);
|
||||
} finally {
|
||||
@@ -336,126 +342,289 @@ function BacktestInner() {
|
||||
}
|
||||
}
|
||||
|
||||
const showErr = (k: string) => (revealErrors ? errors[k] : undefined);
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
title="选股回测"
|
||||
sub="两级截断择股(候选池 n → 持仓 x)+ 择股/调仓双周期(m/y),收盘价撮合,买卖点与个股收益曲线可视化,结果自动归档可复现。"
|
||||
title="回测组合"
|
||||
sub="选一个或多个选股策略,填上回测参数(资金/持仓/持仓天数区间/调仓时机/区间),保存为组合或直接运行;已保存的组合在下方「组合库」里可载入、直接运行、删除。费率与复权口径取自公共配置。"
|
||||
actions={
|
||||
<div className="row" style={{ gap: 8 }}>
|
||||
<Link href="/strategies" className="btn">
|
||||
<span>策略库</span>
|
||||
</Link>
|
||||
<Link href="/experiments" className="btn">
|
||||
<span>实验对比</span>
|
||||
</Link>
|
||||
<Link href="/strategies" className="btn"><span>选股策略库</span></Link>
|
||||
<Link href="/settings" 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>
|
||||
{notice ? <Banner tone="info">{notice}</Banner> : null}
|
||||
|
||||
{/* 公共配置(只读) */}
|
||||
{config ? (
|
||||
<Card icon="database" title="本次回测采用的公共配置(只读)" tools={
|
||||
<Link href="/settings" className="btn btn--sm"><span>去修改</span></Link>
|
||||
}>
|
||||
<div className="chips">
|
||||
<span className="chip">佣金 <b>{(config.commission_rate * 100).toFixed(3)}%</b></span>
|
||||
<span className="chip">印花税 <b>{(config.stamp_tax_rate * 100).toFixed(2)}%</b></span>
|
||||
<span className="chip">滑点 <b>{(config.slippage_rate * 100).toFixed(2)}%</b></span>
|
||||
<span className="chip">最低佣金 <b>{config.min_commission} 元</b></span>
|
||||
<span className="chip">复权 <b>{adjustLabel(config.price_adjustment)}</b></span>
|
||||
<span className="chip">基准 <b className="mono">{config.benchmark}</b></span>
|
||||
</div>
|
||||
<div className="hint" style={{ marginTop: 8 }}>
|
||||
运行时这些值会被**快照**进归档,事后改公共配置不影响这次结果的数字。
|
||||
</div>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
{/* 组合库:保存过的组合在这里能被找回(否则「保存」等于存进黑洞) */}
|
||||
<Card
|
||||
icon="gauge"
|
||||
title="研究参数(Research Specification)"
|
||||
icon="archive"
|
||||
title={`已保存的回测组合${combos ? ` · ${combos.length}` : ""}`}
|
||||
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 size="sm" onClick={startNewCombo} disabled={running}>
|
||||
新建组合
|
||||
</Btn>
|
||||
<Btn icon="book" loading={saving} disabled={running || saving || !params} onClick={saveAsStrategy}>
|
||||
保存为策略
|
||||
<Btn size="sm" onClick={() => void loadCombos()}>
|
||||
刷新
|
||||
</Btn>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{params ? (
|
||||
<>
|
||||
<StrategyParamsForm
|
||||
value={params}
|
||||
onChange={setParams}
|
||||
factorOptions={factors}
|
||||
showMeta
|
||||
showPeriod
|
||||
disabled={running}
|
||||
errors={errors}
|
||||
revealErrors={revealErrors}
|
||||
/>
|
||||
<div className="sticky-bar" style={{ marginTop: 14, position: "static" }}>
|
||||
<Btn variant="primary" icon="play" loading={running} disabled={running} 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>
|
||||
</>
|
||||
{combos === null ? (
|
||||
<Loading label="读取已保存的回测组合…" />
|
||||
) : combos.length === 0 ? (
|
||||
<Empty
|
||||
icon="archive"
|
||||
title="还没有保存过回测组合"
|
||||
hint="勾选策略、填好参数后点「保存为回测组合」,之后就能在这里一键载入或直接运行。"
|
||||
/>
|
||||
) : (
|
||||
<Loading label="准备参数…" />
|
||||
<div className="combo-list">
|
||||
{combos.map((c) => {
|
||||
const isCurrent = savedComboId === c.id;
|
||||
const isRunning = runningComboId === c.id;
|
||||
return (
|
||||
<div key={c.id ?? c.name} className={`combo-item${isCurrent ? " is-on" : ""}`}>
|
||||
<div className="combo-item__main">
|
||||
<div className="row" style={{ gap: 8, flexWrap: "wrap", alignItems: "center" }}>
|
||||
<b>{c.name}</b>
|
||||
{isCurrent ? (
|
||||
<Pill tone="violet" icon="check">当前载入</Pill>
|
||||
) : null}
|
||||
{isRunning ? <Pill tone="warn">运行中…</Pill> : null}
|
||||
<span className="mono hint">{c.id}</span>
|
||||
</div>
|
||||
<div className="chips" style={{ marginTop: 6 }}>
|
||||
<span className="chip">持仓数 <b>{c.hold_count}</b></span>
|
||||
<span className="chip">
|
||||
持仓区间 <b>[{c.hold_min_days}, {c.hold_max_days ?? "∞"}] 天</b>
|
||||
</span>
|
||||
<span className="chip">调仓 <b>{FREQ_LABEL[c.rebalance_freq] ?? c.rebalance_freq}</b></span>
|
||||
<span className="chip">区间 <b>{c.period[0]} ~ {c.period[1]}</b></span>
|
||||
<span className="chip">引用策略 <b>{c.strategy_ids.length}</b> 个</span>
|
||||
</div>
|
||||
{c.description ? <div className="strategy-desc">{c.description}</div> : null}
|
||||
</div>
|
||||
<div className="strategy-actions">
|
||||
<Btn size="sm" icon="edit" disabled={running} onClick={() => loadCombo(c)}>
|
||||
载入到表单
|
||||
</Btn>
|
||||
<Btn
|
||||
size="sm"
|
||||
variant="primary"
|
||||
icon="play"
|
||||
loading={isRunning}
|
||||
disabled={running}
|
||||
onClick={() => void runSaved(c)}
|
||||
>
|
||||
直接运行
|
||||
</Btn>
|
||||
<Btn
|
||||
size="sm"
|
||||
variant="danger"
|
||||
icon="trash"
|
||||
loading={deletingComboId === c.id}
|
||||
disabled={running}
|
||||
onClick={() => void removeCombo(c)}
|
||||
>
|
||||
删除
|
||||
</Btn>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
<div className="hint" style={{ marginTop: 8 }}>
|
||||
「直接运行」用库里保存的那份参数(不受表单里未保存改动影响);「载入到表单」把参数摆出来,
|
||||
改完点上方「更新组合」覆盖,或直接点「运行回测组合」按当前草稿跑。
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* 选股策略多选 */}
|
||||
<Card icon="book" title={`选择选股策略(已选 ${draft.strategyIds.length})`} tools={
|
||||
<span className="hint">多策略取并集后按 Borda 秩和统一打分排序</span>
|
||||
}>
|
||||
{strategies.length === 0 ? (
|
||||
<Empty icon="book" title="还没有选股策略" hint={
|
||||
<Link href="/strategies">先去策略库建一个「怎么选」的策略</Link>
|
||||
} />
|
||||
) : (
|
||||
<div className="strategy-pick">
|
||||
{strategies.map((s) => {
|
||||
const on = draft.strategyIds.includes(s.id ?? "");
|
||||
return (
|
||||
<label key={s.id} className={`strategy-pick__item${on ? " is-on" : ""}`}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={on}
|
||||
onChange={() => s.id && toggleStrategy(s.id)}
|
||||
aria-label={`选择选股策略 ${s.name}`}
|
||||
/>
|
||||
<span className="strategy-pick__name">{s.name}</span>
|
||||
<span className="strategy-pick__meta mono">{s.id}</span>
|
||||
<span className="strategy-pick__desc">{s.description || "(无说明)"}</span>
|
||||
<span className="strategy-pick__factors">
|
||||
{s.factors?.map((f) => f.name).join(" + ") || "—"}
|
||||
</span>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{showErr("strategies") && <div className="field-err" role="alert">{showErr("strategies")}</div>}
|
||||
</Card>
|
||||
|
||||
{/* 回测参数 */}
|
||||
<Card
|
||||
icon="gauge"
|
||||
title={savedComboId ? `回测参数 · 正在编辑组合 ${savedComboId}` : "回测参数 · 新组合"}
|
||||
tools={
|
||||
<div className="row" style={{ gap: 8 }}>
|
||||
<Btn icon="book" loading={saving} disabled={running || saving} onClick={saveCombo}>
|
||||
{savedComboId ? "更新组合" : "保存为回测组合"}
|
||||
</Btn>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="params-form">
|
||||
<div className="form-grid">
|
||||
<Field label="组合名称(保存时必填)" hint="便于在组合列表里识别">
|
||||
<input
|
||||
className={`input${showErr("name") ? " input--invalid" : ""}`}
|
||||
value={draft.name}
|
||||
maxLength={64}
|
||||
placeholder="例:高股息双策略 · 月频"
|
||||
disabled={running}
|
||||
onChange={(e) => setDraft({ ...draft, name: e.target.value })}
|
||||
/>
|
||||
</Field>
|
||||
<Field
|
||||
label="组合说明(可选)"
|
||||
hint={`记下这个组合的用意(${draft.description.length}/300)`}
|
||||
className="field--wide"
|
||||
>
|
||||
<input
|
||||
className="input"
|
||||
value={draft.description}
|
||||
maxLength={300}
|
||||
placeholder="例:用两个高股息选股策略取并集,月频调仓,验证 Tmax=30 天强制了结的影响"
|
||||
disabled={running}
|
||||
onChange={(e) => setDraft({ ...draft, description: e.target.value })}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="起始资金(元)" hint="回测初始本金,默认 100 万">
|
||||
<input
|
||||
className={`input mono${showErr("capital") ? " input--invalid" : ""}`}
|
||||
type="number" inputMode="numeric" step={100000} min={10000}
|
||||
value={draft.initialCapital} disabled={running}
|
||||
onChange={(e) => setDraft({ ...draft, initialCapital: Number(e.target.value) })}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="持仓数 N" hint="目标等权持有的股票只数">
|
||||
<input
|
||||
className={`input mono${showErr("holdCount") ? " input--invalid" : ""}`}
|
||||
type="number" inputMode="numeric" min={1} max={1000}
|
||||
value={draft.holdCount} disabled={running}
|
||||
onChange={(e) => setDraft({ ...draft, holdCount: Number(e.target.value) })}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="调仓时机" hint="多久重新打分并调仓一次">
|
||||
<select
|
||||
className="input"
|
||||
value={draft.rebalanceFreq} disabled={running}
|
||||
onChange={(e) => setDraft({ ...draft, rebalanceFreq: e.target.value as Draft["rebalanceFreq"] })}
|
||||
>
|
||||
<option value="daily">每日</option>
|
||||
<option value="weekly">每周</option>
|
||||
<option value="monthly">每月</option>
|
||||
</select>
|
||||
</Field>
|
||||
<Field label="最短持仓 Tmin(天)" hint="掉出 TopN 时未满此天数暂不卖(防频繁换手);0=不保护">
|
||||
<input
|
||||
className={`input mono${showErr("holdMinDays") ? " input--invalid" : ""}`}
|
||||
type="number" inputMode="numeric" min={0}
|
||||
value={draft.holdMinDays} disabled={running}
|
||||
onChange={(e) => setDraft({ ...draft, holdMinDays: Number(e.target.value) })}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="最长持仓 Tmax(天)" hint="超过即强制了结(每个交易日检查);留空=不限">
|
||||
<div className="row" style={{ gap: 8, alignItems: "center" }}>
|
||||
<input
|
||||
className={`input mono${showErr("holdMaxDays") ? " input--invalid" : ""}`}
|
||||
type="number" inputMode="numeric" min={1}
|
||||
value={draft.holdMaxDays ?? ""} disabled={running}
|
||||
placeholder="不限"
|
||||
onChange={(e) => setDraft({ ...draft, holdMaxDays: e.target.value === "" ? null : Number(e.target.value) })}
|
||||
/>
|
||||
<label className="check" style={{ whiteSpace: "nowrap" }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={draft.holdMaxDays === null}
|
||||
disabled={running}
|
||||
onChange={(e) => setDraft({ ...draft, holdMaxDays: e.target.checked ? null : 30 })}
|
||||
/>
|
||||
不限
|
||||
</label>
|
||||
</div>
|
||||
</Field>
|
||||
<Field label="开始日期" className={showErr("period") ? "field--invalid" : undefined}>
|
||||
<input type="date" className="input" value={draft.start} disabled={running}
|
||||
onChange={(e) => setDraft({ ...draft, start: e.target.value })} />
|
||||
</Field>
|
||||
<Field label="结束日期" className={showErr("period") ? "field--invalid" : undefined}>
|
||||
<input type="date" className="input" value={draft.end} disabled={running}
|
||||
onChange={(e) => setDraft({ ...draft, end: e.target.value })} />
|
||||
</Field>
|
||||
</div>
|
||||
{showErr("period") && <div className="field-err" role="alert">{showErr("period")}</div>}
|
||||
|
||||
<div className="sticky-bar" style={{ marginTop: 14, position: "static" }}>
|
||||
<Btn variant="primary" icon="play" loading={running} disabled={running} onClick={run}>
|
||||
{running ? "后台运行中…" : "运行回测组合"}
|
||||
</Btn>
|
||||
<span className="hint">
|
||||
N={draft.holdCount} · [{draft.holdMinDays}, {draft.holdMaxDays ?? "∞"}] 天 ·{" "}
|
||||
{draft.rebalanceFreq === "daily" ? "日频" : draft.rebalanceFreq === "weekly" ? "周频" : "月频"} ·{" "}
|
||||
{draft.start} ~ {draft.end} · {draft.strategyIds.length} 个策略
|
||||
</span>
|
||||
{blocking.length ? (
|
||||
<Pill tone="warn" icon="alert">有 {blocking.length} 处需要修正</Pill>
|
||||
) : (
|
||||
<Pill tone="pos" icon="check">参数校验通过</Pill>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{running ? (
|
||||
<div className="stages" style={{ marginTop: 12 }}>
|
||||
{["data_loading", "selection", "backtesting", "analysis"].map((st) => {
|
||||
const order = ["queued", "data_loading", "selection", "backtesting", "analysis", "done"];
|
||||
{["data_loading", "backtesting", "analysis"].map((st) => {
|
||||
const order = ["queued", "data_loading", "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";
|
||||
@@ -466,31 +635,21 @@ function BacktestInner() {
|
||||
);
|
||||
})}
|
||||
<span className="hint">
|
||||
作业 <span className="mono">{jobId || "排队中…"}</span> · 已用{" "}
|
||||
{Math.round(elapsed / 1000)}s(全市场 8 年区间约 3~5 分钟,可离开本页,结果会归档)
|
||||
作业 <span className="mono">{jobId || "排队中…"}</span> · 已用 {Math.round(elapsed / 1000)}s
|
||||
(全市场多年区间约 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="可点「载入高股息案例默认参数」一键填充;调好后点「保存为策略」即可在策略库复用、对比。"
|
||||
/>
|
||||
<Empty icon="gauge" title="尚未运行回测"
|
||||
hint="先勾选至少一个选股策略、填好回测参数,再点「运行回测组合」。可先「保存为回测组合」以便复用。" />
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
{result ? <BacktestResultView result={result} name={params?.name ?? ""} /> : null}
|
||||
{result ? <BacktestResultView result={result} name={draft.name || "回测组合"} /> : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user