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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,610 @@
|
||||
/**
|
||||
* 归档详情页(`/experiments/{id}`)—— 「往复查看」的落点。
|
||||
*
|
||||
* 为什么单独一页,而不复用回测页:
|
||||
* 回测页是**可编辑参数**的工作台,从它看归档会被误认为「当前参数就是归档参数」;
|
||||
* 归档需要的是一个**只读、口径固定、可分享**的视图:URL 即快照地址,刷新/换设备都能看,
|
||||
* 且必须明确回答两个问题 ——
|
||||
* ① **选股条件**:这次回测到底按什么规则选股(因子/权重/条件/股票池/两级截断/周期/补位)
|
||||
* ② **交易执行依据**:成交时点、价格口径、成本、涨跌停与停牌怎么处理、期末是否平仓
|
||||
* 这两块来自归档里存的 **spec(复现依据)**,不来自当前页面状态,因此永不漂移;
|
||||
* 同时用后端 `describe_strategy`(与引擎实执行规则同源)生成完整说明与计算公式。
|
||||
*
|
||||
* 结果区交给 `ArchiveResultView` 按归档 `kind` 分发(backtest/factor_test/selection 结构不同,
|
||||
* 不能假定回测字段)——回测归档与刚跑完时看到的**完全同一套图表与表格**,
|
||||
* 避免「归档里少一张图」的隐性不一致。
|
||||
*
|
||||
* **这里是 Server Component**:归档是只读内容,选股条件/执行依据/元数据必须在
|
||||
* 服务端直出(否则不带 JS 的抓取与链接预览拿到的只是一个 Loading 骨架——
|
||||
* 实测 SSR HTML 里搜不到「选股条件」)。图表与删除/导出等交互由客户端子组件承担。
|
||||
*/
|
||||
import { notFound } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { ArchiveActions } from "@/components/ArchiveActions";
|
||||
import { StrategyDocCard } from "@/components/StrategyDocCard";
|
||||
import { ArchiveResultView } from "@/components/ArchiveResultView";
|
||||
import { archivedFieldSummary } from "@/lib/archive";
|
||||
import { PageHeader, Card, Pill, Empty, Banner } from "@/components/ui";
|
||||
import { adjustLabel, experimentKindLabel, rebalanceLabel } from "@/lib/labels";
|
||||
import { RichText } from "@/components/RichText";
|
||||
import type { ExperimentDetail, FactorMeta, ResearchCondition, ResearchSpec, StrategyDoc } from "@/lib/types";
|
||||
|
||||
export const dynamic = "force-dynamic"; // 归档可能被删除/新增,禁止静态化缓存
|
||||
|
||||
/** 服务端访问后端:与 next.config.ts 的代理同一默认地址(服务端没有「同源」可用) */
|
||||
const BACKEND = (process.env.BACKEND_API_URL ?? "http://127.0.0.1:8000").replace(/\/$/, "");
|
||||
|
||||
async function serverGet<T>(path: string): Promise<T | null> {
|
||||
try {
|
||||
const r = await fetch(`${BACKEND}/api${path}`, { cache: "no-store" });
|
||||
if (!r.ok) return null;
|
||||
return (await r.json()) as T;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function serverPost<T>(path: string, body: unknown): Promise<T | null> {
|
||||
try {
|
||||
const r = await fetch(`${BACKEND}/api${path}`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
cache: "no-store",
|
||||
});
|
||||
if (!r.ok) return null;
|
||||
return (await r.json()) as T;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export default async function ExperimentArchivePage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ id: string }>;
|
||||
}) {
|
||||
const { id: rawId } = await params;
|
||||
const id = decodeURIComponent(rawId ?? "");
|
||||
|
||||
const [detail, factors] = await Promise.all([
|
||||
serverGet<ExperimentDetail>(`/experiments/${encodeURIComponent(id)}`),
|
||||
serverGet<FactorMeta[]>("/factors"),
|
||||
]);
|
||||
// 归档不存在 → 交给 Next 的 404(比渲染一个空壳页更诚实)
|
||||
if (!detail) notFound();
|
||||
|
||||
const spec = (detail.spec ?? null) as Partial<ResearchSpec> | null;
|
||||
const result = detail.result;
|
||||
// 关键字段计数按 kind 给不同口径(用回测字段去数因子/选股结果是错的)
|
||||
const fieldSummary = archivedFieldSummary(detail.kind, result);
|
||||
// 与引擎执行规则同源的说明(后端按归档 spec 推导;推导失败时如实显示错误)
|
||||
// 只有回测归档才需要「完整说明与计算公式」:describe 推导的是回测策略口径,
|
||||
// 因子测试/选股用同一 spec 结构但规则并未执行,拉回来展示等于误导(且白花一次请求)。
|
||||
const doc = spec && detail.kind === "backtest"
|
||||
? await serverPost<StrategyDoc & { doc?: StrategyDoc }>("/strategies/describe", spec)
|
||||
: null;
|
||||
const docBody = doc ? ((doc as { doc?: StrategyDoc }).doc ?? doc) : null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
title={`归档 ${detail.id}`}
|
||||
sub={`${experimentKindLabel(detail.kind)} · ${detail.summary_text ?? "无摘要"}`}
|
||||
/>
|
||||
|
||||
<ArchiveActions id={detail.id} spec={detail.spec} />
|
||||
|
||||
<ArchivedSpecCards detail={detail} spec={spec} factors={factors ?? []} />
|
||||
|
||||
{detail.kind === "backtest" ? (
|
||||
<StrategyDocCard
|
||||
doc={docBody}
|
||||
loading={false}
|
||||
error={spec && !docBody ? "后端未能给出该 spec 的说明(describe 接口失败)" : ""}
|
||||
title="完整说明与计算公式(后端按归档 spec 推导,与引擎执行规则同源)"
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<Card icon="archive" title="归档元数据(复现依据)">
|
||||
<div className="kv-grid">
|
||||
<KV label="实验 ID" value={detail.id} mono />
|
||||
<KV label="类型" value={experimentKindLabel(detail.kind)} />
|
||||
<KV label="归档时间" value={fmtTime(detail.created_at)} mono />
|
||||
<KV label="代码版本" value={detail.code_version ?? "—"} mono />
|
||||
<KV
|
||||
label="数据版本"
|
||||
value={detail.data_version ?? "未记录(该归档早于数据指纹上线)"}
|
||||
mono
|
||||
warn={!detail.data_version}
|
||||
/>
|
||||
<KV label="来源作业" value={detail.job_id ?? "同步接口调用(无作业 id)"} mono />
|
||||
<KV
|
||||
label="归档体积"
|
||||
value={detail.result_bytes ? `${(detail.result_bytes / 1024).toFixed(0)} KB` : "—"}
|
||||
/>
|
||||
<KV
|
||||
label="区间"
|
||||
value={detail.period ? `${detail.period[0]} ~ ${detail.period[1]}` : "—"}
|
||||
mono
|
||||
/>
|
||||
</div>
|
||||
<div className="hint" style={{ marginTop: 10 }}>
|
||||
<RichText text="数据版本由后端在归档时写入(数据快照指纹):**同样的代码版本 + 同样的数据版本 + 同样的 spec,才应当复现出同样的结果**。若数据版本显示「未记录」,说明该归档产生于指纹上线之前 —— 此时不要把它当作可严格复现的依据。" />
|
||||
</div>
|
||||
<div className="row" style={{ marginTop: 10, gap: 8 }}>
|
||||
{fieldSummary ? <span className="hint">{fieldSummary}</span> : null}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{result ? (
|
||||
<ArchiveResultView
|
||||
kind={detail.kind}
|
||||
result={result}
|
||||
archive={{
|
||||
id: detail.id,
|
||||
created_at: detail.created_at,
|
||||
code_version: detail.code_version,
|
||||
data_version: detail.data_version,
|
||||
job_id: detail.job_id,
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<Card>
|
||||
<Empty
|
||||
icon="archive"
|
||||
title="该归档没有可展示的结果"
|
||||
hint={
|
||||
detail.kind === "factor_test"
|
||||
? "因子测试归档没有净值曲线(结果结构不同);结果为空时只能导出 JSON 查看原始内容。"
|
||||
: "结果为空(可能是历史版本归档)。可用上方「导出完整 JSON」查看原始内容。"
|
||||
}
|
||||
/>
|
||||
</Card>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
function KV({
|
||||
label,
|
||||
value,
|
||||
mono,
|
||||
warn,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
mono?: boolean;
|
||||
warn?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className="kv">
|
||||
<span className="kv__k">{label}</span>
|
||||
<span className={`kv__v${mono ? " mono" : ""}${warn ? " kv__v--warn" : ""}`}>{value}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function fmtTime(v?: string | null): string {
|
||||
if (!v) return "—";
|
||||
const d = new Date(v);
|
||||
if (Number.isNaN(d.getTime())) return v;
|
||||
const p = (n: number) => String(n).padStart(2, "0");
|
||||
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`;
|
||||
}
|
||||
|
||||
function directionText(name: string, factors: FactorMeta[]): string {
|
||||
const f = factors.find((x) => x.name === name);
|
||||
if (!f) return "—";
|
||||
return f.direction === "lower_is_better" ? "越低越好" : "越高越好";
|
||||
}
|
||||
|
||||
function opText(op?: string): string {
|
||||
switch (op) {
|
||||
case "gt":
|
||||
return ">";
|
||||
case "gte":
|
||||
return "≥";
|
||||
case "lt":
|
||||
return "<";
|
||||
case "lte":
|
||||
return "≤";
|
||||
case "eq":
|
||||
return "=";
|
||||
case "ne":
|
||||
return "≠";
|
||||
case "in":
|
||||
return "∈";
|
||||
case "not_in":
|
||||
return "∉";
|
||||
default:
|
||||
return op ?? "?";
|
||||
}
|
||||
}
|
||||
|
||||
function condText(c: ResearchCondition): string {
|
||||
const right = c.ref ? `(字段 ${c.ref})` : `${c.value ?? ""}`;
|
||||
return `${c.field} ${opText(c.op)} ${right}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 因子测试归档的 spec 卡:只展示**真正生效**的字段。
|
||||
*
|
||||
* 诚实性要点(AGENT §7/§24):因子测试按横截面算 IC / RankIC / 分层收益,
|
||||
* **不做两级截断、不下单、不计成本**。但归档 spec 沿用了与回测相同的请求结构
|
||||
* (含 `selection` / `costs` / `initial_capital`)—— 把这些字段按回测口径展示,
|
||||
* 会让人以为这次跑过调仓与撮合。所以这里只列生效项,并显式说明哪些字段未生效。
|
||||
*/
|
||||
function FactorTestSpecCards({
|
||||
spec,
|
||||
factors,
|
||||
}: {
|
||||
spec: Partial<ResearchSpec>;
|
||||
factors: FactorMeta[];
|
||||
}) {
|
||||
const uni = (spec.universe ?? {}) as Record<string, unknown>;
|
||||
const factorList = (spec.factors ?? []) as { name: string; weight: number }[];
|
||||
const period = spec.period ?? [];
|
||||
return (
|
||||
<>
|
||||
<Card
|
||||
icon="flask"
|
||||
title="因子测试配置(本次实际生效的字段)"
|
||||
tools={<Pill tone="violet">来自归档 spec,非当前页面参数</Pill>}
|
||||
>
|
||||
<div className="spec-grid">
|
||||
<SpecBlock title="被测因子(横截面 z-score 加权)">
|
||||
{factorList.length === 0 ? (
|
||||
<SpecRow k="因子" v="—" />
|
||||
) : (
|
||||
factorList.map((f) => (
|
||||
<SpecRow
|
||||
key={f.name}
|
||||
k={f.name}
|
||||
v={`权重 ${f.weight} ${directionText(f.name, factors)}`}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</SpecBlock>
|
||||
<SpecBlock title="横截面范围(universe)">
|
||||
<SpecRow k="市场" v={String(uni.market ?? "CN_A")} />
|
||||
<SpecRow k="剔除 ST" v={uni.exclude_st ? "是(按当时名称判定)" : "否"} />
|
||||
<SpecRow
|
||||
k="剔除停牌"
|
||||
v={uni.exclude_suspended ? "是(近似:当日无行情)" : "否"}
|
||||
/>
|
||||
<SpecRow
|
||||
k="上市天数下限"
|
||||
v={uni.min_listing_days ? `${String(uni.min_listing_days)} 天` : "无限制"}
|
||||
/>
|
||||
</SpecBlock>
|
||||
<SpecBlock title="测试区间">
|
||||
<SpecRow
|
||||
k="区间"
|
||||
v={period.length === 2 ? `${period[0]} ~ ${period[1]}` : "—"}
|
||||
mono
|
||||
/>
|
||||
</SpecBlock>
|
||||
</div>
|
||||
<div className="hint" style={{ marginTop: 10 }}>
|
||||
<RichText text="本次归档类型是**因子测试**:按横截面计算 IC / RankIC / 分层收益,**不做两级截断(n→x)、不下单、不计交易成本**。归档 spec 里沿用了与回测相同的请求结构(`selection` / `costs` / `initial_capital` 等),但这些字段本次**未生效** —— 不要把它们当作执行依据。" />
|
||||
</div>
|
||||
</Card>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 选股归档的 spec 卡:一次性选股的规则(无调仓、无成交)。
|
||||
*/
|
||||
function SelectionSpecCards({
|
||||
spec,
|
||||
factors,
|
||||
}: {
|
||||
spec: Partial<ResearchSpec>;
|
||||
factors: FactorMeta[];
|
||||
}) {
|
||||
const uni = (spec.universe ?? {}) as Record<string, unknown>;
|
||||
const factorList = (spec.factors ?? []) as { name: string; weight: number }[];
|
||||
const conds = ((spec as Record<string, unknown>).conditions ?? []) as ResearchCondition[];
|
||||
const method = String((spec as Record<string, unknown>).method ?? "score");
|
||||
const topN = (spec as Record<string, unknown>).top_n;
|
||||
const topPct = (spec as Record<string, unknown>).top_pct;
|
||||
return (
|
||||
<Card
|
||||
icon="target"
|
||||
title="选股条件(本次一次性选股实际使用的规则)"
|
||||
tools={<Pill tone="violet">来自归档 spec,非当前页面参数</Pill>}
|
||||
>
|
||||
<div className="spec-grid">
|
||||
<SpecBlock title="时点与方式">
|
||||
<SpecRow
|
||||
k="选股时点"
|
||||
v={String((spec as Record<string, unknown>).as_of ?? "最近交易日")}
|
||||
mono
|
||||
/>
|
||||
<SpecRow k="方式" v={method === "condition" ? "条件筛选" : "因子评分"} />
|
||||
<SpecRow
|
||||
k="复权口径"
|
||||
v={spec.price_adjustment === "hfq" ? "后复权(hfq)" : "不复权(none)"}
|
||||
/>
|
||||
</SpecBlock>
|
||||
<SpecBlock title={method === "condition" ? "筛选条件(AND)" : "打分因子(横截面 z-score 加权)"}>
|
||||
{method === "condition"
|
||||
? conds.length === 0
|
||||
? <SpecRow k="条件" v="—" />
|
||||
: conds.map((c) => <SpecRow key={condText(c)} k={c.field} v={condText(c)} />)
|
||||
: factorList.length === 0
|
||||
? <SpecRow k="因子" v="—" />
|
||||
: factorList.map((f) => (
|
||||
<SpecRow
|
||||
key={f.name}
|
||||
k={f.name}
|
||||
v={`权重 ${f.weight} ${directionText(f.name, factors)}`}
|
||||
/>
|
||||
))}
|
||||
</SpecBlock>
|
||||
<SpecBlock title="股票池与选出数量">
|
||||
<SpecRow k="市场" v={String(uni.market ?? "CN_A")} />
|
||||
<SpecRow k="剔除 ST" v={uni.exclude_st ? "是(按当时名称判定)" : "否"} />
|
||||
<SpecRow
|
||||
k="剔除停牌"
|
||||
v={uni.exclude_suspended ? "是(近似:当日无行情)" : "否"}
|
||||
/>
|
||||
<SpecRow
|
||||
k="选出数量"
|
||||
v={
|
||||
topN
|
||||
? `Top ${String(topN)}`
|
||||
: topPct
|
||||
? `前 ${String(topPct)}%`
|
||||
: "—"
|
||||
}
|
||||
/>
|
||||
</SpecBlock>
|
||||
</div>
|
||||
<div className="hint" style={{ marginTop: 10 }}>
|
||||
<RichText text="本次归档类型是**一次性选股**:只在上述**单个交易日**求值一次,**没有调仓周期、没有成交与成本**(不会买卖任何东西)。归档 spec 里若出现 `costs` / `rebalance`,那只是同一请求结构的字段,本次未生效。" />
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
/** 选股条件 + 交易执行依据:直接用归档 spec 渲染,回答「这次回测到底怎么选的、怎么成交的」 */
|
||||
function ArchivedSpecCards({
|
||||
detail,
|
||||
spec,
|
||||
factors,
|
||||
}: {
|
||||
detail: ExperimentDetail;
|
||||
spec: Partial<ResearchSpec> | null;
|
||||
factors: FactorMeta[];
|
||||
}) {
|
||||
if (!spec) {
|
||||
return (
|
||||
<Card icon="book" title="选股条件与交易执行依据">
|
||||
<div className="hint">
|
||||
该归档没有存储 spec(早于 spec 归档上线),无法回溯当时的规则;只能看下方结果。
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// 按归档类型分发:**只有回测**才有「选股条件 + 交易执行依据」这一对。
|
||||
// 因子测试/选股与回测共用同一请求结构(spec 里也有 selection/costs/initial_capital),
|
||||
// 但那些字段在因子测试/选股里**从未生效** —— 沿用回测口径展示等于谎报执行依据。
|
||||
if (detail.kind === "factor_test") {
|
||||
return <FactorTestSpecCards spec={spec} factors={factors} />;
|
||||
}
|
||||
if (detail.kind === "selection") {
|
||||
return <SelectionSpecCards spec={spec} factors={factors} />;
|
||||
}
|
||||
if (detail.kind !== "backtest") {
|
||||
return (
|
||||
<Card icon="book" title={`归档 spec(类型:${detail.kind})`}>
|
||||
<div className="hint">
|
||||
<RichText text="该类型的 spec 展示口径尚未定义,这里**不猜测**它的含义;原始 spec 可用上方「查看原始 spec(JSON)」逐字查看。" />
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
const uni = (spec.universe ?? {}) as Record<string, unknown>;
|
||||
const sel = (spec.selection ?? {}) as Record<string, unknown>;
|
||||
const costs = (spec.costs ?? {}) as Record<string, unknown>;
|
||||
const conds = (spec.conditions ?? []) as ResearchCondition[];
|
||||
const factorList = (spec.factors ?? []) as { name: string; weight: number }[];
|
||||
const fillPolicy =
|
||||
sel.defer_buy === true
|
||||
? "顺延买入(等它到之后首个不涨停的交易日按收盘价买)"
|
||||
: sel.allow_substitute === false
|
||||
? "不补位(买不进就空着,实际持仓可能少于目标数)"
|
||||
: "替补买入(从候选池之外按复合分往下找可买标的)";
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card
|
||||
icon="target"
|
||||
title="选股条件(本次回测实际使用的规则)"
|
||||
tools={<Pill tone="violet">来自归档 spec,非当前页面参数</Pill>}
|
||||
>
|
||||
<div className="spec-grid">
|
||||
<SpecBlock title="股票池(universe)">
|
||||
<SpecRow k="市场" v={String(uni.market ?? "CN_A")} />
|
||||
<SpecRow
|
||||
k="剔除 ST"
|
||||
v={uni.exclude_st ? "是(按择股日**当时名称**判定)" : "否"}
|
||||
/>
|
||||
<SpecRow
|
||||
k="上市天数下限"
|
||||
v={uni.min_listing_days ? `${uni.min_listing_days} 个自然日` : "无限制"}
|
||||
/>
|
||||
<SpecRow k="指数成分" v={uni.index_code ? String(uni.index_code) : "不限定"} />
|
||||
<SpecRow
|
||||
k="白名单"
|
||||
v={
|
||||
Array.isArray(uni.symbols) && uni.symbols.length
|
||||
? `${uni.symbols.length} 只`
|
||||
: "全市场"
|
||||
}
|
||||
/>
|
||||
<SpecRow k="剔除停牌" v={uni.exclude_suspended ? "是(近似:当日无行情)" : "否"} />
|
||||
</SpecBlock>
|
||||
|
||||
<SpecBlock title="打分因子(横截面 z-score 加权)">
|
||||
{factorList.length === 0 ? (
|
||||
<div className="hint">无(纯条件选股)</div>
|
||||
) : (
|
||||
factorList.map((f) => (
|
||||
<SpecRow
|
||||
key={f.name}
|
||||
k={`${f.name} 权重 ${f.weight}`}
|
||||
v={directionText(f.name, factors)}
|
||||
mono
|
||||
/>
|
||||
))
|
||||
)}
|
||||
<div className="hint" style={{ marginTop: 6 }}>
|
||||
复合分 = Σ(权重 × 方向 × 当日截面 z-score),降序取候选池。
|
||||
</div>
|
||||
</SpecBlock>
|
||||
|
||||
<SpecBlock title="过滤条件(AND,逐择股日求值)">
|
||||
{conds.length === 0 ? (
|
||||
<div className="hint">无附加条件</div>
|
||||
) : (
|
||||
conds.map((c, i) => <SpecRow key={`${c.field}-${i}`} k={`条件 ${i + 1}`} v={condText(c)} mono />)
|
||||
)}
|
||||
</SpecBlock>
|
||||
|
||||
<SpecBlock title="两级截断与周期">
|
||||
<SpecRow k="候选池 n" v={sel.top_n != null ? `${sel.top_n} 只` : "—"} />
|
||||
<SpecRow
|
||||
k="实际持仓 x"
|
||||
v={
|
||||
sel.hold_top_x != null && sel.hold_top_x !== sel.top_n
|
||||
? `${sel.hold_top_x} 只`
|
||||
: `= n${sel.top_n != null ? `(${sel.top_n} 只)` : ""}`
|
||||
}
|
||||
/>
|
||||
<SpecRow
|
||||
k="择股周期 m"
|
||||
v={
|
||||
spec.selection_interval_months
|
||||
? `每 ${spec.selection_interval_months} 个月重新择股`
|
||||
: "每次调仓都重新择股"
|
||||
}
|
||||
/>
|
||||
<SpecRow
|
||||
k="调仓周期 y"
|
||||
v={
|
||||
spec.rebalance_interval_months
|
||||
? `每 ${spec.rebalance_interval_months} 个月调仓`
|
||||
: `跟随调仓频率(${rebalanceLabel(spec.rebalance)})`
|
||||
}
|
||||
/>
|
||||
<SpecRow k="买不进时" v={fillPolicy} />
|
||||
<SpecRow k="资金分配" v="等权(可用现金 / 目标持仓数)" />
|
||||
</SpecBlock>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card icon="scale" title="交易执行依据(撮合价 / 成本 / 约束)">
|
||||
<div className="spec-grid">
|
||||
<SpecBlock title="成交口径">
|
||||
<SpecRow k="价格口径" v={adjustLabel(spec.price_adjustment as string)} />
|
||||
<SpecRow k="成交时点" v="调仓日**收盘**(调仓在当日收盘生效,自次日起计收益)" />
|
||||
<SpecRow
|
||||
k="买入价"
|
||||
v={`收盘价 × (1 + 滑点 ${pct(costs.slippage_rate)})`}
|
||||
/>
|
||||
<SpecRow
|
||||
k="卖出价"
|
||||
v={`收盘价 × (1 − 滑点 ${pct(costs.slippage_rate)})`}
|
||||
/>
|
||||
<SpecRow k="回测区间" v={detail.period ? `${detail.period[0]} ~ ${detail.period[1]}` : "—"} mono />
|
||||
<SpecRow
|
||||
k="初始资金"
|
||||
v={
|
||||
spec.initial_capital != null
|
||||
? `${Number(spec.initial_capital).toLocaleString("zh-CN")} 元`
|
||||
: "—"
|
||||
}
|
||||
/>
|
||||
</SpecBlock>
|
||||
|
||||
<SpecBlock title="成本">
|
||||
<SpecRow k="佣金" v={`${pct(costs.commission_rate)}(买入与卖出各计一次)`} />
|
||||
<SpecRow k="印花税" v={`${pct(costs.stamp_tax_rate)}(仅卖出)`} />
|
||||
<SpecRow k="滑点" v={pct(costs.slippage_rate)} />
|
||||
<SpecRow
|
||||
k="最低佣金"
|
||||
v={
|
||||
Number(costs.min_commission ?? 0) > 0
|
||||
? `${costs.min_commission} 元/笔(max(按比例, 最低))`
|
||||
: "未启用(0)"
|
||||
}
|
||||
/>
|
||||
<SpecRow
|
||||
k="对照基准"
|
||||
v={String(costs.benchmark ?? "000300.SH") + "(仅展示,不参与交易)"}
|
||||
/>
|
||||
</SpecBlock>
|
||||
|
||||
<SpecBlock title="成交约束(近似建模)">
|
||||
<SpecRow k="涨停" v="收盘价 / 上一有效收盘 ≥ 板块涨停系数 → 不可买入" />
|
||||
<SpecRow k="跌停" v="→ 不可卖出(持仓保留到下一次调仓)" />
|
||||
<SpecRow k="停牌" v="当日无行情 → 不可买不可卖" />
|
||||
<SpecRow k="顺延买入" v={sel.defer_buy ? "启用(到下次调仓仍未成交则作废,资金留作现金)" : "未启用"} />
|
||||
<SpecRow k="期末平仓" v="否 —— 最后持仓按最后交易日收盘估值,浮盈浮亏计入净值但不产生 Trade 记录" />
|
||||
</SpecBlock>
|
||||
|
||||
<SpecBlock title="复现提示">
|
||||
<div className="hint">
|
||||
① 同样的 spec + 同样的代码版本 + 同样的数据版本才应复现同样结果;
|
||||
② 复权口径为 {adjustLabel(spec.price_adjustment as string)},
|
||||
{spec.price_adjustment === "none"
|
||||
? "现金分红未计入收益、除权日价格下移会记为亏损 —— 股息类策略建议用 hfq 重跑对比。"
|
||||
: "分红按复权因子隐含再投资处理。"}
|
||||
③ 未建模项(涨跌停开盘路径、停牌明细、流动性冲击等)写在该归档结果的
|
||||
<RichText text="`unimplemented`" /> 列表里,下方结果区已如实展示。
|
||||
</div>
|
||||
</SpecBlock>
|
||||
</div>
|
||||
{detail.kind !== "backtest" ? (
|
||||
<div className="hint" style={{ marginTop: 10 }}>
|
||||
该归档类型为「{experimentKindLabel(detail.kind)}」,其 spec 中的择股/调仓/成本字段不参与计算。
|
||||
</div>
|
||||
) : null}
|
||||
</Card>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function SpecBlock({ title, children }: { title: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="spec-block">
|
||||
<div className="spec-block__title">{title}</div>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SpecRow({ k, v, mono }: { k: string; v: string; mono?: boolean }) {
|
||||
return (
|
||||
<div className="spec-row">
|
||||
<span className="spec-row__k">{k}</span>
|
||||
<span className={`spec-row__v${mono ? " mono" : ""}`}>
|
||||
<RichText text={v} />
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function pct(v: unknown): string {
|
||||
const n = Number(v);
|
||||
if (!Number.isFinite(n)) return "未记录";
|
||||
return `${(n * 100).toFixed(3).replace(/0+$/, "").replace(/\.$/, "")}%`;
|
||||
}
|
||||
@@ -1,8 +1,24 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { apiGet, apiPost } from "@/lib/api";
|
||||
import type { BacktestResult } from "@/lib/types";
|
||||
/**
|
||||
* 实验归档 + **对比**(微调闭环的核心)。
|
||||
*
|
||||
* 过去这一页只能「看详情 / 复跑」,微调时只能翻列表比数字。现在:
|
||||
* - 勾选 2~3 个实验 → 净值曲线归一化为「累计收益率 %」叠加(消除初始资金差异),
|
||||
* 因此可以直接看出「改了哪个参数、曲线变好还是变坏」;
|
||||
* - 指标对比表:每个指标一行、每个实验一列,最后一列是与第一个实验的**差值**;
|
||||
* - 参数 diff 表:把 config_snapshot 扁平化后逐项对比,默认只显示「有差异」的项 ——
|
||||
* 微调时一眼看出「这次到底改了什么」。
|
||||
*
|
||||
* 曲线用 TradingView Lightweight Charts(全站统一图表基座)。
|
||||
*/
|
||||
import { Suspense, useCallback, useEffect, useMemo, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { apiGet, apiGetWithHeaders, apiPost } from "@/lib/api";
|
||||
import { experimentKindLabel } from "@/lib/labels";
|
||||
import type { BacktestResult, ExperimentDetail, ExperimentMeta } from "@/lib/types";
|
||||
import { isBacktestDetail, type BacktestDetail } from "@/lib/archive";
|
||||
import {
|
||||
PageHeader,
|
||||
Card,
|
||||
@@ -12,70 +28,98 @@ import {
|
||||
Empty,
|
||||
Metric,
|
||||
SkeletonLines,
|
||||
Loading,
|
||||
} from "@/components/ui";
|
||||
import { Icon } from "@/components/icons";
|
||||
import { LwChart, type LwSeries } from "@/components/charts/LwChart";
|
||||
import { CHART, seriesColor } from "@/components/charts/theme";
|
||||
|
||||
interface ExperimentMeta {
|
||||
id: string;
|
||||
kind: string;
|
||||
factors: string[];
|
||||
period?: [string, string] | null;
|
||||
rebalance?: string;
|
||||
top_n?: number | null;
|
||||
summary_text?: string | null;
|
||||
code_version?: string | null;
|
||||
created_at?: string;
|
||||
}
|
||||
|
||||
interface ExperimentDetail extends ExperimentMeta {
|
||||
spec: { universe?: Record<string, unknown> };
|
||||
result: BacktestResult | null;
|
||||
}
|
||||
|
||||
const KIND_MAP: Record<string, { label: string; tone: "accent" | "violet" | "default" }> = {
|
||||
backtest: { label: "回测", tone: "accent" },
|
||||
factor_test: { label: "因子测试", tone: "violet" },
|
||||
/**
|
||||
* 类型徽标的**色调**(措辞统一走 `lib/labels.ts::experimentKindLabel`)。
|
||||
*
|
||||
* 之前这里自己维护了一份 `KIND_MAP` 文案,只覆盖 backtest/factor_test,
|
||||
* 于是 `selection` 在表格里显示成英文原值 `selection`,而同一页的筛选下拉写「选股」、
|
||||
* 归档页写「选股」—— 同一口径三种叫法。现在文案只有一份,这里只管颜色。
|
||||
*/
|
||||
const KIND_TONE: Record<string, "accent" | "violet" | "default"> = {
|
||||
backtest: "accent",
|
||||
factor_test: "violet",
|
||||
selection: "default",
|
||||
};
|
||||
|
||||
function kindView(kind: string) {
|
||||
const k = KIND_MAP[kind] ?? { label: kind, tone: "default" as const };
|
||||
return <Pill tone={k.tone}>{k.label}</Pill>;
|
||||
return <Pill tone={KIND_TONE[kind] ?? "default"}>{experimentKindLabel(kind)}</Pill>;
|
||||
}
|
||||
|
||||
function statusView(status: string) {
|
||||
switch (status) {
|
||||
case "success":
|
||||
return (
|
||||
<Pill tone="pos" icon="check">
|
||||
完成
|
||||
</Pill>
|
||||
);
|
||||
case "running":
|
||||
return (
|
||||
<Pill tone="accent" icon="spinner">
|
||||
运行中
|
||||
</Pill>
|
||||
);
|
||||
case "queued":
|
||||
return (
|
||||
<Pill tone="warn" icon="clock">
|
||||
排队
|
||||
</Pill>
|
||||
);
|
||||
return <Pill tone="accent">排队中</Pill>;
|
||||
case "running":
|
||||
return <Pill tone="accent" icon="spinner">执行中</Pill>;
|
||||
case "success":
|
||||
return <Pill tone="pos" icon="check">完成</Pill>;
|
||||
case "failed":
|
||||
return (
|
||||
<Pill tone="neg" icon="alert">
|
||||
失败
|
||||
</Pill>
|
||||
);
|
||||
case "cancelled":
|
||||
return <Pill tone="warn">已取消</Pill>;
|
||||
return <Pill tone="neg" icon="alert">失败</Pill>;
|
||||
default:
|
||||
return <Pill>{status}</Pill>;
|
||||
return <Pill>{status || "—"}</Pill>;
|
||||
}
|
||||
}
|
||||
|
||||
/** config_snapshot 扁平化:嵌套对象转成 a.b.c 路径,便于逐项 diff */
|
||||
function flatten(obj: unknown, prefix = "", out: Record<string, string> = {}): Record<string, string> {
|
||||
if (obj === null || obj === undefined) {
|
||||
out[prefix || "—"] = "—";
|
||||
return out;
|
||||
}
|
||||
if (Array.isArray(obj)) {
|
||||
out[prefix] = obj.length ? JSON.stringify(obj) : "[]";
|
||||
return out;
|
||||
}
|
||||
if (typeof obj === "object") {
|
||||
const entries = Object.entries(obj as Record<string, unknown>);
|
||||
if (!entries.length) {
|
||||
out[prefix || "—"] = "{}";
|
||||
return out;
|
||||
}
|
||||
for (const [k, v] of entries) flatten(v, prefix ? `${prefix}.${k}` : k, out);
|
||||
return out;
|
||||
}
|
||||
out[prefix || "—"] = String(obj);
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 数据指纹在列表里只显示关键前缀(完整值放 title,悬停可见) */
|
||||
function shortDataVersion(v: string): string {
|
||||
return v.length > 28 ? `${v.slice(0, 28)}…` : v;
|
||||
}
|
||||
|
||||
const METRICS: { key: keyof BacktestResult["summary"]; label: string; fmt: (v: number) => string; better?: "high" | "low" }[] = [
|
||||
{ key: "total_return_pct", label: "区间收益", fmt: (v) => `${v >= 0 ? "+" : ""}${v.toFixed(2)}%`, better: "high" },
|
||||
{ key: "annual_return_pct", label: "年化收益", fmt: (v) => `${v >= 0 ? "+" : ""}${v.toFixed(2)}%`, better: "high" },
|
||||
{ key: "sharpe", label: "Sharpe", fmt: (v) => v.toFixed(3), better: "high" },
|
||||
{ key: "max_drawdown_pct", label: "最大回撤", fmt: (v) => `${v.toFixed(2)}%`, better: "low" },
|
||||
{ key: "volatility_pct", label: "年化波动", fmt: (v) => `${v.toFixed(2)}%`, better: "low" },
|
||||
{ key: "win_rate_pct", label: "胜率", fmt: (v) => `${v.toFixed(2)}%`, better: "high" },
|
||||
{ key: "total_trades", label: "成交笔数", fmt: (v) => String(v) },
|
||||
{ key: "avg_turnover_pct", label: "平均换手", fmt: (v) => `${v.toFixed(2)}%` },
|
||||
{ key: "final_equity", label: "期末权益", fmt: (v) => v.toLocaleString("zh-CN", { maximumFractionDigits: 0 }) },
|
||||
];
|
||||
|
||||
/**
|
||||
* Next 15 要求 `useSearchParams` 处于 Suspense 边界内。
|
||||
* `?exp=EXP-x,EXP-y` 用于从其它页面直接带实验进对比(策略库一键回测后「去对比」)。
|
||||
*/
|
||||
export default function ExperimentsPage() {
|
||||
return (
|
||||
<Suspense fallback={<Loading label="加载实验页…" />}>
|
||||
<ExperimentsInner />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
function ExperimentsInner() {
|
||||
const search = useSearchParams();
|
||||
const [exps, setExps] = useState<ExperimentMeta[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [detail, setDetail] = useState<ExperimentDetail | null>(null);
|
||||
@@ -84,22 +128,69 @@ export default function ExperimentsPage() {
|
||||
const [jobStatus, setJobStatus] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
|
||||
// 对比状态
|
||||
const [picked, setPicked] = useState<string[]>([]);
|
||||
const [compare, setCompare] = useState<BacktestDetail[] | null>(null);
|
||||
const [comparing, setComparing] = useState(false);
|
||||
const [onlyDiff, setOnlyDiff] = useState(true);
|
||||
|
||||
// 列表过滤:后端支持 kind/q(并把过滤后总数放在 X-Total-Count 响应头)。
|
||||
// 初始值取自 URL(`?q=`/`?kind=`),且每次改动**回写 URL**:筛选条件可分享、刷新不丢,
|
||||
// 也让「当前看到的是哪一批」有据可查(而不是只存在于组件状态里)。
|
||||
const [q, setQ] = useState(() => search.get("q") ?? "");
|
||||
const [kind, setKind] = useState(() => search.get("kind") ?? "");
|
||||
const [total, setTotal] = useState<number | null>(null);
|
||||
|
||||
const syncUrl = useCallback(
|
||||
(nextQ: string, nextKind: string) => {
|
||||
const params = new URLSearchParams();
|
||||
if (nextKind) params.set("kind", nextKind);
|
||||
if (nextQ.trim()) params.set("q", nextQ.trim());
|
||||
// 保留已勾选的对比实验,避免筛一下就丢掉选择
|
||||
const exp = search.get("exp");
|
||||
if (exp) params.set("exp", exp);
|
||||
const qs = params.toString();
|
||||
window.history.replaceState(null, "", qs ? `/experiments?${qs}` : "/experiments");
|
||||
},
|
||||
[search]
|
||||
);
|
||||
|
||||
const load = useCallback(() => {
|
||||
apiGet<ExperimentMeta[]>("/experiments")
|
||||
.then(setExps)
|
||||
const params = new URLSearchParams();
|
||||
if (kind) params.set("kind", kind);
|
||||
if (q.trim()) params.set("q", q.trim());
|
||||
const qs = params.toString();
|
||||
apiGetWithHeaders<ExperimentMeta[]>(`/experiments${qs ? `?${qs}` : ""}`)
|
||||
.then(({ data, headers }) => {
|
||||
setExps(data);
|
||||
const t = headers.get("X-Total-Count");
|
||||
setTotal(t ? Number(t) : data.length);
|
||||
})
|
||||
.catch((e: Error) => setError(e.message))
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
}, [q, kind]);
|
||||
|
||||
useEffect(load, [load]);
|
||||
|
||||
// 从 URL 预勾选要对比的实验(最多 3 个,与手动勾选同一上限)
|
||||
useEffect(() => {
|
||||
const raw = search.get("exp");
|
||||
if (!raw) return;
|
||||
const ids = raw
|
||||
.split(",")
|
||||
.map((x) => x.trim())
|
||||
.filter(Boolean)
|
||||
.slice(0, 3);
|
||||
if (ids.length) setPicked(ids);
|
||||
// 仅在首次进入时读一次,避免用户手动改选后被 URL 覆盖
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
function open(id: string) {
|
||||
setDetailLoading(true);
|
||||
setDetail(null);
|
||||
apiGet<ExperimentDetail>(`/experiments/${id}`)
|
||||
.then((d) => {
|
||||
setDetail(d);
|
||||
})
|
||||
.then((d) => setDetail(d))
|
||||
.catch((e: Error) => setError(e.message))
|
||||
.finally(() => setDetailLoading(false));
|
||||
}
|
||||
@@ -134,31 +225,135 @@ export default function ExperimentsPage() {
|
||||
}, 1200);
|
||||
}
|
||||
|
||||
const s = detail?.result?.summary;
|
||||
function togglePick(id: string) {
|
||||
setError("");
|
||||
setPicked((prev) => {
|
||||
if (prev.includes(id)) return prev.filter((x) => x !== id);
|
||||
if (prev.length >= 3) {
|
||||
setError("最多同时对比 3 个实验(再多曲线会互相遮挡,也不利于读图)");
|
||||
return prev;
|
||||
}
|
||||
return [...prev, id];
|
||||
});
|
||||
}
|
||||
|
||||
async function runCompare() {
|
||||
setComparing(true);
|
||||
setError("");
|
||||
try {
|
||||
const rows = await Promise.all(
|
||||
picked.map((id) => apiGet<ExperimentDetail>(`/experiments/${id}`))
|
||||
);
|
||||
const usable = rows.filter(isBacktestDetail);
|
||||
if (usable.length < 2) {
|
||||
setError("所选实验里可对比的净值曲线不足 2 条(因子测试没有净值曲线)。");
|
||||
setCompare(null);
|
||||
} else {
|
||||
setCompare(usable);
|
||||
}
|
||||
} catch (e) {
|
||||
setError((e as Error).message);
|
||||
} finally {
|
||||
setComparing(false);
|
||||
}
|
||||
}
|
||||
|
||||
const s = detail && isBacktestDetail(detail) ? detail.result.summary : undefined;
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
title="实验归档"
|
||||
sub="每次因子测试与回测自动归档(含 Research Spec 与代码版本),保证可复现;支持一键复跑。"
|
||||
actions={exps.length > 0 ? <Pill tone="accent" icon="archive">共 {exps.length} 个实验</Pill> : undefined}
|
||||
title="实验对比"
|
||||
sub="每次因子测试与回测自动归档(含 Research Spec 与代码版本),保证可复现;勾选 2~3 个即可叠加净值曲线、对比指标与参数差异。"
|
||||
actions={
|
||||
exps.length > 0 ? (
|
||||
<Pill tone="accent" icon="archive">
|
||||
共 {exps.length} 个实验
|
||||
</Pill>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
|
||||
{error ? <Banner tone="error">{error}</Banner> : null}
|
||||
|
||||
{picked.length > 0 ? (
|
||||
<div className="sticky-bar">
|
||||
<Pill tone="violet" icon="layers">
|
||||
已选 {picked.length} 个
|
||||
</Pill>
|
||||
<span className="hint">{picked.join(" · ")}</span>
|
||||
<Btn variant="primary" icon="chartLine" loading={comparing} disabled={comparing || picked.length < 2} onClick={runCompare}>
|
||||
对比选中的实验
|
||||
</Btn>
|
||||
<Btn icon="x" onClick={() => { setPicked([]); setCompare(null); }}>
|
||||
清空
|
||||
</Btn>
|
||||
{picked.length < 2 ? <span className="hint">再选 1 个即可对比</span> : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{compare ? (
|
||||
<CompareView rows={compare} onlyDiff={onlyDiff} setOnlyDiff={setOnlyDiff} onClose={() => setCompare(null)} />
|
||||
) : null}
|
||||
|
||||
{jobId ? (
|
||||
<Card
|
||||
icon="refresh"
|
||||
title={`复跑任务 ${jobId}`}
|
||||
tools={statusView(jobStatus === "submitting" ? "queued" : jobStatus)}
|
||||
>
|
||||
<div className="hint">
|
||||
任务状态实时轮询;完成后列表自动刷新,可直接展开查看最新结果。
|
||||
</div>
|
||||
<div className="hint">任务状态实时轮询;完成后列表自动刷新,可直接展开查看最新结果。</div>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
<Card title="实验列表" icon="archive" flush>
|
||||
<Card
|
||||
id="list"
|
||||
icon="archive"
|
||||
title="实验列表"
|
||||
sub={
|
||||
total !== null
|
||||
? `显示 ${exps.length} 条 / 共 ${total} 条${
|
||||
total > exps.length ? "(后端单页上限内未列全,可用搜索或类型筛选缩小范围)" : ""
|
||||
}`
|
||||
: "勾选左侧方框可选 2~3 个做对比"
|
||||
}
|
||||
tools={
|
||||
<div className="row" style={{ gap: 8 }}>
|
||||
<input
|
||||
className="input"
|
||||
style={{ width: 200 }}
|
||||
placeholder="搜索 ID / 因子 / 摘要"
|
||||
value={q}
|
||||
onChange={(e) => {
|
||||
setQ(e.target.value);
|
||||
syncUrl(e.target.value, kind);
|
||||
}}
|
||||
aria-label="搜索实验"
|
||||
/>
|
||||
<select
|
||||
className="input"
|
||||
style={{ width: 130 }}
|
||||
value={kind}
|
||||
onChange={(e) => {
|
||||
setKind(e.target.value);
|
||||
syncUrl(q, e.target.value);
|
||||
}}
|
||||
aria-label="按类型筛选"
|
||||
>
|
||||
<option value="">全部类型</option>
|
||||
<option value="backtest">回测</option>
|
||||
<option value="factor_test">因子测试</option>
|
||||
<option value="selection">选股</option>
|
||||
</select>
|
||||
{q || kind ? (
|
||||
<Btn size="sm" icon="x" onClick={() => { setQ(""); setKind(""); syncUrl("", ""); }}>
|
||||
清除
|
||||
</Btn>
|
||||
) : null}
|
||||
</div>
|
||||
}
|
||||
flush
|
||||
>
|
||||
{loading ? (
|
||||
<div style={{ padding: 18 }}>
|
||||
<SkeletonLines n={6} />
|
||||
@@ -167,25 +362,35 @@ export default function ExperimentsPage() {
|
||||
<Empty
|
||||
icon="archive"
|
||||
title="暂无实验"
|
||||
hint="在「回测」或「因子研究」中运行一次,即会自动归档于此。"
|
||||
hint="在「选股回测」或「因子研究」中运行一次,即会自动归档于此。"
|
||||
/>
|
||||
) : (
|
||||
<div className="table-wrap">
|
||||
<table className="tbl">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{ width: 36 }} aria-label="选择对比" />
|
||||
<th>ID</th>
|
||||
<th>类型</th>
|
||||
<th>因子</th>
|
||||
<th>区间</th>
|
||||
<th>摘要</th>
|
||||
<th>代码版本</th>
|
||||
<th style={{ width: 150, textAlign: "right" }}>操作</th>
|
||||
<th>版本(代码 / 数据)</th>
|
||||
<th>体积</th>
|
||||
<th style={{ width: 260, textAlign: "right" }}>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{exps.map((e) => (
|
||||
<tr key={e.id} className={detail?.id === e.id ? "row-active" : undefined}>
|
||||
<td>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={picked.includes(e.id)}
|
||||
onChange={() => togglePick(e.id)}
|
||||
aria-label={`选择 ${e.id} 用于对比`}
|
||||
/>
|
||||
</td>
|
||||
<td className="cell-mono cell-strong">{e.id}</td>
|
||||
<td>{kindView(e.kind)}</td>
|
||||
<td>
|
||||
@@ -199,13 +404,43 @@ export default function ExperimentsPage() {
|
||||
</td>
|
||||
<td className="cell-mono">{e.period ? `${e.period[0]}~${e.period[1]}` : "—"}</td>
|
||||
<td className="hint">{e.summary_text ?? "—"}</td>
|
||||
<td>{e.code_version ? <span className="tag tag--mono">{e.code_version}</span> : <span className="hint">—</span>}</td>
|
||||
<td>
|
||||
<span className="row" style={{ gap: 4 }}>
|
||||
{e.code_version ? (
|
||||
<span className="tag tag--mono" title="代码版本">{e.code_version}</span>
|
||||
) : (
|
||||
<span className="hint">—</span>
|
||||
)}
|
||||
{e.data_version ? (
|
||||
<span className="tag tag--mono" title={`数据快照:${e.data_version}`}>
|
||||
{shortDataVersion(e.data_version)}
|
||||
</span>
|
||||
) : (
|
||||
<span className="hint" title="该归档早于数据指纹上线,不可严格复现">无数据版本</span>
|
||||
)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="mono dim">
|
||||
{e.result_bytes ? `${(e.result_bytes / 1024).toFixed(0)} KB` : "—"}
|
||||
</td>
|
||||
<td style={{ textAlign: "right" }}>
|
||||
<span className="row" style={{ justifyContent: "flex-end", gap: 6 }}>
|
||||
<Link href={`/experiments/${encodeURIComponent(e.id)}`} className="btn btn--sm">
|
||||
<span>打开归档</span>
|
||||
</Link>
|
||||
<Btn size="sm" icon="search" onClick={() => open(e.id)}>
|
||||
详情
|
||||
</Btn>
|
||||
<Btn size="sm" variant="primary" icon="refresh" onClick={() => rerun(e)} disabled={jobStatus === "running" || jobStatus === "queued" || jobStatus === "submitting"}>
|
||||
<Link href={`/backtest?from_experiment=${encodeURIComponent(e.id)}`} className="btn btn--sm">
|
||||
<span>以此参数再跑</span>
|
||||
</Link>
|
||||
<Btn
|
||||
size="sm"
|
||||
variant="primary"
|
||||
icon="refresh"
|
||||
onClick={() => rerun(e)}
|
||||
disabled={jobStatus === "running" || jobStatus === "queued" || jobStatus === "submitting"}
|
||||
>
|
||||
复跑
|
||||
</Btn>
|
||||
</span>
|
||||
@@ -224,10 +459,15 @@ export default function ExperimentsPage() {
|
||||
title={`${detail.id} · ${detail.factors.join("、")}`}
|
||||
sub={detail.period ? `${detail.period[0]} ~ ${detail.period[1]}` : undefined}
|
||||
tools={
|
||||
<Btn size="sm" onClick={() => setDetail(null)}>
|
||||
<Icon name="x" size={13} />
|
||||
关闭
|
||||
</Btn>
|
||||
<div className="row" style={{ gap: 8 }}>
|
||||
<Link href={`/experiments/${encodeURIComponent(detail.id)}`} className="btn btn--sm">
|
||||
<span>打开归档(完整视图)</span>
|
||||
</Link>
|
||||
<Btn size="sm" onClick={() => setDetail(null)}>
|
||||
<Icon name="x" size={13} />
|
||||
关闭
|
||||
</Btn>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{detailLoading ? (
|
||||
@@ -252,12 +492,36 @@ export default function ExperimentsPage() {
|
||||
) : (
|
||||
<div className="hint">该实验没有可展示的汇总结果(可能是因子测试或历史版本)。</div>
|
||||
)}
|
||||
{detail.spec ? (
|
||||
<div className="hint" style={{ marginTop: 12 }}>
|
||||
spec(可复现输入):<span className="mono">{JSON.stringify(detail.spec).slice(0, 500)}</span>
|
||||
{JSON.stringify(detail.spec).length > 500 ? "…" : ""}
|
||||
{detail && isBacktestDetail(detail) && detail.result.equity_curve.length ? (
|
||||
<div style={{ marginTop: 12 }}>
|
||||
<LwChart
|
||||
series={[
|
||||
{
|
||||
key: "eq",
|
||||
label: "累计收益率(%)",
|
||||
type: "area",
|
||||
color: CHART.accent,
|
||||
data: normalize(detail.result),
|
||||
lastValueVisible: true,
|
||||
},
|
||||
]}
|
||||
height={260}
|
||||
valueFormat={(v) => `${v.toFixed(2)}%`}
|
||||
zeroLine
|
||||
ariaLabel={`${detail.id} 累计收益率曲线`}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
{detail.spec ? (
|
||||
<details style={{ marginTop: 12 }}>
|
||||
<summary className="hint" style={{ cursor: "pointer" }}>
|
||||
查看 spec(可复现输入)
|
||||
</summary>
|
||||
<pre className="mono" style={{ whiteSpace: "pre-wrap", fontSize: 12 }}>
|
||||
{JSON.stringify(detail.spec, null, 2)}
|
||||
</pre>
|
||||
</details>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</Card>
|
||||
@@ -265,3 +529,198 @@ export default function ExperimentsPage() {
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
/** 净值曲线 → 累计收益率 %(以首个点为基准,消除初始资金差异后可直接叠加) */
|
||||
function normalize(r: BacktestResult): { time: string; value: number }[] {
|
||||
const base = r.equity_curve[0]?.value || 1;
|
||||
return r.equity_curve.map((p) => ({ time: p.date, value: (p.value / base - 1) * 100 }));
|
||||
}
|
||||
|
||||
/** 参数签名:一眼看出这一版的关键旋钮(微调时最重要) */
|
||||
function sig(r: BacktestResult): string {
|
||||
const c = r.config_snapshot as {
|
||||
selection?: { top_n?: number; hold_top_x?: number | null };
|
||||
selection_interval_months?: number | null;
|
||||
rebalance_interval_months?: number | null;
|
||||
price_adjustment?: string;
|
||||
factors?: { name: string; weight: number }[];
|
||||
costs?: { commission_rate?: number; min_commission?: number };
|
||||
universe?: { exclude_st?: boolean; min_listing_days?: number };
|
||||
};
|
||||
const parts = [
|
||||
(c.factors ?? []).map((f) => f.name).join("+") || "—",
|
||||
`n=${c.selection?.top_n ?? "—"}/x=${c.selection?.hold_top_x ?? "—"}`,
|
||||
`m=${c.selection_interval_months ?? "—"}/y=${c.rebalance_interval_months ?? "—"}`,
|
||||
c.price_adjustment ?? "—",
|
||||
c.universe?.exclude_st ? "剔ST" : "含ST",
|
||||
`佣金${(((c.costs?.commission_rate ?? 0) * 100) as number).toFixed(3)}%`,
|
||||
];
|
||||
return parts.join(" · ");
|
||||
}
|
||||
|
||||
function CompareView({
|
||||
rows,
|
||||
onlyDiff,
|
||||
setOnlyDiff,
|
||||
onClose,
|
||||
}: {
|
||||
rows: BacktestDetail[];
|
||||
onlyDiff: boolean;
|
||||
setOnlyDiff: (v: boolean) => void;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const series = useMemo<LwSeries[]>(
|
||||
() =>
|
||||
rows.map((r, i) => ({
|
||||
key: r.id,
|
||||
label: `${r.id}(${sig(r.result)})`,
|
||||
color: seriesColor(i),
|
||||
data: normalize(r.result),
|
||||
type: "line" as const,
|
||||
lineWidth: 2 as const,
|
||||
lastValueVisible: i === 0,
|
||||
})),
|
||||
[rows]
|
||||
);
|
||||
|
||||
// 参数 diff:扁平化后并集,只显示有差异的项(默认)
|
||||
const diffRows = useMemo(() => {
|
||||
const flat = rows.map((r) => flatten(r.result?.config_snapshot ?? {}));
|
||||
const keys = Array.from(new Set(flat.flatMap((f) => Object.keys(f)))).sort();
|
||||
return keys
|
||||
// price_basis.* 是回测运行元数据(数据快照/复权缺口核对),不是用户可调的旋钮:
|
||||
// 混进「参数差异」表只会制造噪声(复权口径本身在顶层 price_adjustment 里已展示)
|
||||
.filter((k) => !k.startsWith("price_basis."))
|
||||
.map((k) => ({ key: k, vals: flat.map((f) => f[k] ?? "—") }))
|
||||
.filter((row) => (onlyDiff ? new Set(row.vals).size > 1 : true));
|
||||
}, [rows, onlyDiff]);
|
||||
|
||||
const base = rows[0].result.summary;
|
||||
|
||||
return (
|
||||
<Card
|
||||
icon="chartLine"
|
||||
title={`实验对比 · ${rows.length} 个`}
|
||||
sub={rows.map((r) => r.id).join(" vs ")}
|
||||
tools={
|
||||
<div className="row" style={{ gap: 8 }}>
|
||||
<label className="cmp-badge" style={{ cursor: "pointer" }}>
|
||||
<input type="checkbox" checked={onlyDiff} onChange={(e) => setOnlyDiff(e.target.checked)} />
|
||||
参数只显示有差异项
|
||||
</label>
|
||||
<Btn size="sm" onClick={onClose}>
|
||||
<Icon name="x" size={13} />
|
||||
关闭对比
|
||||
</Btn>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<LwChart
|
||||
series={series}
|
||||
height={360}
|
||||
valueFormat={(v) => `${v.toFixed(2)}%`}
|
||||
zeroLine
|
||||
ariaLabel="实验净值曲线叠加对比"
|
||||
/>
|
||||
<div className="hint" style={{ marginTop: 6 }}>
|
||||
曲线已归一化为**累计收益率 %**(各自以首个净值为 0%),因此初始资金不同也能直接比。
|
||||
点击图例可临时隐藏某条曲线(只算收益差的实验建议隐藏基准再比)。
|
||||
</div>
|
||||
|
||||
<div className="table-wrap" style={{ marginTop: 14 }}>
|
||||
<table className="tbl">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>指标</th>
|
||||
{rows.map((r) => (
|
||||
<th key={r.id}>{r.id}</th>
|
||||
))}
|
||||
<th>相对 {rows[0].id} 的差值</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{METRICS.map((m) => {
|
||||
const vals = rows.map((r) => Number(r.result!.summary[m.key] ?? 0));
|
||||
const delta = vals[vals.length - 1] - vals[0];
|
||||
const good =
|
||||
m.better === undefined
|
||||
? null
|
||||
: m.better === "high"
|
||||
? delta > 0
|
||||
: delta < 0;
|
||||
return (
|
||||
<tr key={String(m.key)}>
|
||||
<td className="cell-strong">{m.label}</td>
|
||||
{vals.map((v, i) => (
|
||||
<td key={rows[i].id} className="num">
|
||||
{m.fmt(v)}
|
||||
{i === 0 ? <span className="hint"> (基准)</span> : null}
|
||||
</td>
|
||||
))}
|
||||
<td className={delta === 0 ? "cmp-diff-same" : good ? "cmp-diff-add" : "cmp-diff-del"}>
|
||||
{m.better === undefined
|
||||
? m.fmt(delta)
|
||||
: `${delta >= 0 ? "+" : ""}${m.fmt(delta)}`}
|
||||
{good !== null && delta !== 0 ? (good ? " ✅" : " ⚠️") : ""}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div className="hint" style={{ marginTop: 6 }}>
|
||||
差额列 = 最后一个实验 − 基准({rows[0].id})。✅ 表示该指标改善方向,⚠️ 表示变差
|
||||
(收益/夏普/胜率越高越好,回撤/波动越低越好)。基准区间收益{" "}
|
||||
{base.total_return_pct.toFixed(2)}%。
|
||||
</div>
|
||||
|
||||
<div style={{ marginTop: 16 }}>
|
||||
<b style={{ fontSize: 13 }}>参数差异(config_snapshot 逐项对比)</b>
|
||||
{diffRows.length === 0 ? (
|
||||
<div className="hint" style={{ marginTop: 6 }}>
|
||||
这几个实验的参数快照完全一致(说明结果是**同一配置**下的数据变动,或只是复跑)。
|
||||
已排除 price_basis.* 运行元数据。
|
||||
</div>
|
||||
) : (
|
||||
<div className="table-wrap" style={{ marginTop: 6 }}>
|
||||
<table className="tbl">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>参数</th>
|
||||
{rows.map((r) => (
|
||||
<th key={r.id}>{r.id}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{diffRows.map((row) => (
|
||||
<tr key={row.key}>
|
||||
<td className="cell-mono">{row.key}</td>
|
||||
{row.vals.map((v, i) => (
|
||||
<td
|
||||
key={rows[i].id}
|
||||
className={
|
||||
new Set(row.vals).size > 1 && v !== row.vals[0] ? "cmp-diff-add num" : "num"
|
||||
}
|
||||
>
|
||||
{v}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
{onlyDiff ? (
|
||||
<div className="hint" style={{ marginTop: 6 }}>
|
||||
已隐藏所有实验取值相同的参数(取消上方勾选可看全量)。
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -18,7 +18,8 @@ import {
|
||||
MonthlyReturnsTable,
|
||||
UnimplementedNote,
|
||||
} from "@/components/ui";
|
||||
import { LineChart } from "@/components/LineChart";
|
||||
import { LwChart, type LwSeries } from "@/components/charts/LwChart";
|
||||
import { CHART, fmtNum, fmtPct } from "@/components/charts/theme";
|
||||
import { Icon } from "@/components/icons";
|
||||
|
||||
interface Pick {
|
||||
@@ -291,6 +292,27 @@ function ResultView({
|
||||
params: { picks: Pick[]; topN: number; rebalance: string; excludeSt: boolean; start: string; end: string };
|
||||
}) {
|
||||
const s = result.summary;
|
||||
|
||||
// 曲线数据点:LwChart 的 time 用 "YYYY-MM-DD" 字符串,向后端 CurvePoint 的 date 直接映射
|
||||
const equitySeries: LwSeries[] = [
|
||||
{
|
||||
key: "equity",
|
||||
label: "净值",
|
||||
type: "area",
|
||||
color: CHART.pos,
|
||||
data: result.equity_curve.map((p) => ({ time: p.date, value: p.value })),
|
||||
},
|
||||
];
|
||||
const drawdownSeries: LwSeries[] = [
|
||||
{
|
||||
key: "drawdown",
|
||||
label: "回撤",
|
||||
type: "line",
|
||||
color: CHART.neg,
|
||||
data: result.drawdown.map((p) => ({ time: p.date, value: p.value })),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="between" style={{ margin: "6px 0 14px" }}>
|
||||
@@ -312,10 +334,23 @@ function ResultView({
|
||||
|
||||
<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)} />
|
||||
<LwChart
|
||||
series={equitySeries}
|
||||
height={300}
|
||||
valueFormat={(v) => fmtNum(v, 2)}
|
||||
ariaLabel="净值曲线"
|
||||
emptyHint="该区间没有净值数据"
|
||||
/>
|
||||
</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) + "%"} />
|
||||
<LwChart
|
||||
series={drawdownSeries}
|
||||
height={300}
|
||||
zeroLine
|
||||
valueFormat={(v) => fmtPct(v, 2)}
|
||||
ariaLabel="回撤曲线"
|
||||
emptyHint="该区间没有回撤数据"
|
||||
/>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1290,3 +1290,518 @@ table.tbl {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
图表(TradingView Lightweight Charts)—— 图例 / tooltip / 容器
|
||||
============================================================ */
|
||||
|
||||
.lw-wrap {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--sp-2);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.lw-legend {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--sp-1) var(--sp-2);
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.lw-legend-item {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 3px 8px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 999px;
|
||||
background: var(--surface-2);
|
||||
color: var(--text-2);
|
||||
font-size: var(--fs-xs);
|
||||
cursor: pointer;
|
||||
transition: opacity 0.12s ease, border-color 0.12s ease, background 0.12s ease;
|
||||
}
|
||||
|
||||
.lw-legend-item:hover {
|
||||
border-color: var(--line-strong);
|
||||
background: var(--surface-3);
|
||||
}
|
||||
|
||||
.lw-legend-item:focus-visible {
|
||||
outline: 2px solid var(--focus);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
.lw-legend-item i {
|
||||
width: 9px;
|
||||
height: 9px;
|
||||
border-radius: 2px;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.lw-legend-item b {
|
||||
color: var(--text-1);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.lw-legend-item.is-off {
|
||||
opacity: 0.42;
|
||||
text-decoration: line-through;
|
||||
}
|
||||
|
||||
.lw-canvas {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* tooltip:LW 无内置实现,由 LwChart 订阅十字光标渲染 */
|
||||
.lw-tip {
|
||||
position: absolute;
|
||||
z-index: 5;
|
||||
pointer-events: none;
|
||||
min-width: 132px;
|
||||
padding: 7px 9px;
|
||||
border: 1px solid rgba(150, 165, 195, 0.28);
|
||||
border-radius: var(--r-sm);
|
||||
background: rgba(20, 29, 48, 0.96);
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
|
||||
font-size: var(--fs-xs);
|
||||
}
|
||||
|
||||
.lw-tip-time {
|
||||
color: var(--text-3);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.lw-tip-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.lw-tip-row i {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 2px;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.lw-tip-label {
|
||||
color: var(--text-2);
|
||||
flex: 1 1 auto;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.lw-tip-row b {
|
||||
color: var(--text-1);
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
股票代码 + 名称(任何出现代码的地方都必须成对出现且可点击)
|
||||
============================================================ */
|
||||
|
||||
.symlink {
|
||||
display: inline-flex;
|
||||
align-items: baseline;
|
||||
gap: 6px;
|
||||
max-width: 100%;
|
||||
padding: 1px 3px;
|
||||
margin: -1px -3px;
|
||||
border-radius: var(--r-xs);
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
transition: background 0.12s ease;
|
||||
}
|
||||
|
||||
.symlink:hover {
|
||||
background: var(--accent-soft);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.symlink:focus-visible {
|
||||
outline: 2px solid var(--focus);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
.symlink .sym-code {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.92em;
|
||||
color: var(--text-2);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.symlink:hover .sym-code {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.symlink .sym-name {
|
||||
color: var(--text-1);
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
max-width: 9em;
|
||||
}
|
||||
|
||||
.symlink .sym-name--missing {
|
||||
color: var(--text-3);
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
/* 名称列优先:窄屏时允许名称换行而不是把代码挤掉 */
|
||||
.sym-cell {
|
||||
min-width: 132px;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
策略说明与计算公式
|
||||
============================================================ */
|
||||
|
||||
.doc-block {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--sp-2);
|
||||
}
|
||||
|
||||
.doc-summary {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--sp-2);
|
||||
padding: 10px 12px;
|
||||
border: 1px solid var(--line);
|
||||
border-left: 3px solid var(--accent);
|
||||
border-radius: var(--r-sm);
|
||||
background: linear-gradient(90deg, rgba(63, 182, 255, 0.08), transparent 70%);
|
||||
color: var(--text-1);
|
||||
line-height: 1.65;
|
||||
}
|
||||
|
||||
.doc-formula {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.doc-formula pre {
|
||||
margin: 0;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--r-sm);
|
||||
background: var(--bg-1);
|
||||
color: var(--text-1);
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--fs-xs);
|
||||
line-height: 1.75;
|
||||
overflow-x: auto;
|
||||
white-space: pre;
|
||||
}
|
||||
|
||||
.doc-steps {
|
||||
margin: 0;
|
||||
padding-left: 18px;
|
||||
color: var(--text-2);
|
||||
font-size: var(--fs-sm);
|
||||
line-height: 1.8;
|
||||
}
|
||||
|
||||
.doc-steps li b {
|
||||
color: var(--text-1);
|
||||
}
|
||||
|
||||
/* 策略卡片 */
|
||||
.strategy-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(360px, 1fr));
|
||||
gap: var(--sp-4);
|
||||
}
|
||||
|
||||
.strategy-desc {
|
||||
color: var(--text-2);
|
||||
line-height: 1.7;
|
||||
font-size: var(--fs-sm);
|
||||
}
|
||||
|
||||
.strategy-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--sp-2);
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
实验对比
|
||||
============================================================ */
|
||||
|
||||
.cmp-toolbar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--sp-2);
|
||||
}
|
||||
|
||||
.cmp-diff-add {
|
||||
color: var(--pos);
|
||||
}
|
||||
.cmp-diff-del {
|
||||
color: var(--neg);
|
||||
}
|
||||
.cmp-diff-same {
|
||||
color: var(--text-3);
|
||||
}
|
||||
|
||||
.cmp-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: var(--fs-xs);
|
||||
color: var(--text-3);
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
易用性:粘性操作条 / 结果锚点 / 键盘提示
|
||||
============================================================ */
|
||||
|
||||
.sticky-bar {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 20;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--sp-3);
|
||||
padding: 10px 12px;
|
||||
margin-bottom: var(--sp-3);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--r-md);
|
||||
background: rgba(10, 15, 28, 0.92);
|
||||
backdrop-filter: blur(8px);
|
||||
}
|
||||
|
||||
.kbd {
|
||||
display: inline-block;
|
||||
padding: 1px 5px;
|
||||
border: 1px solid var(--line-strong);
|
||||
border-bottom-width: 2px;
|
||||
border-radius: 4px;
|
||||
background: var(--surface-2);
|
||||
color: var(--text-2);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* 表单校验:错误态 + 行内提示 */
|
||||
.field--invalid input,
|
||||
.field--invalid select {
|
||||
border-color: var(--neg);
|
||||
}
|
||||
|
||||
.field-err {
|
||||
color: var(--neg);
|
||||
font-size: var(--fs-xs);
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
/* 可点击行(表格) */
|
||||
.tbl tbody tr.row-clickable {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* 进度阶段列表 */
|
||||
.stages {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--sp-2);
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.stage {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 3px 9px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 999px;
|
||||
background: var(--surface-2);
|
||||
color: var(--text-3);
|
||||
font-size: var(--fs-xs);
|
||||
}
|
||||
|
||||
.stage.is-done {
|
||||
color: var(--pos);
|
||||
border-color: rgba(61, 220, 151, 0.35);
|
||||
}
|
||||
|
||||
.stage.is-active {
|
||||
color: var(--accent);
|
||||
border-color: rgba(63, 182, 255, 0.45);
|
||||
background: var(--accent-soft);
|
||||
}
|
||||
|
||||
/* 面包屑 / 返回 */
|
||||
.crumb {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
color: var(--text-3);
|
||||
font-size: var(--fs-sm);
|
||||
}
|
||||
|
||||
.crumb a {
|
||||
color: var(--text-2);
|
||||
}
|
||||
|
||||
.crumb a:hover {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
/* ---------- 补充:既有页面已在用但此前没有样式规则的类 ---------- */
|
||||
|
||||
/* .dim:次要信息(日期/计数),此前无规则导致与正文同色,层级不清 */
|
||||
.dim {
|
||||
color: var(--text-3);
|
||||
}
|
||||
|
||||
/* .input--invalid:单个输入框的错误态(.field--invalid 是整字段态) */
|
||||
.input--invalid,
|
||||
.input--invalid:focus {
|
||||
border-color: var(--neg);
|
||||
box-shadow: 0 0 0 3px var(--neg-soft);
|
||||
}
|
||||
|
||||
/* ---------- 单选组(买不进补位策略:三选一) ---------- */
|
||||
.radio-col {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
.radio-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 7px;
|
||||
font-size: 13px;
|
||||
color: var(--text-2);
|
||||
cursor: pointer;
|
||||
line-height: 1.45;
|
||||
}
|
||||
.radio-row input {
|
||||
margin-top: 2px;
|
||||
flex: none;
|
||||
}
|
||||
.radio-row:hover {
|
||||
color: var(--text-1);
|
||||
}
|
||||
|
||||
/* ---------- 归档详情:元数据条 / KV / 规格表 / 代码块 ---------- */
|
||||
|
||||
/* 归档元数据与完整度提示条:信息密度高但不抢图表注意力 */
|
||||
.archive-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
padding: 9px 12px;
|
||||
margin: 0 0 10px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--r-sm);
|
||||
background: linear-gradient(180deg, var(--surface-2), var(--surface-1));
|
||||
}
|
||||
|
||||
.kv-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
|
||||
gap: 8px 18px;
|
||||
}
|
||||
|
||||
.kv {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 8px;
|
||||
font-size: 13px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.kv__k {
|
||||
flex: none;
|
||||
color: var(--text-3);
|
||||
}
|
||||
|
||||
.kv__v {
|
||||
color: var(--text-1);
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.kv__v--warn {
|
||||
color: var(--warn);
|
||||
}
|
||||
|
||||
/* 规格表:选股条件 / 交易执行依据 */
|
||||
.spec-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.spec-block {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--r-sm);
|
||||
padding: 10px 12px;
|
||||
background: var(--surface-1);
|
||||
}
|
||||
|
||||
.spec-block__title {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--text-2);
|
||||
margin-bottom: 8px;
|
||||
padding-bottom: 6px;
|
||||
border-bottom: 1px dashed var(--line);
|
||||
}
|
||||
|
||||
.spec-row {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: baseline;
|
||||
font-size: 13px;
|
||||
padding: 3px 0;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.spec-row__k {
|
||||
flex: none;
|
||||
min-width: 96px;
|
||||
color: var(--text-3);
|
||||
}
|
||||
|
||||
.spec-row__v {
|
||||
color: var(--text-1);
|
||||
min-width: 0;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.codeblock {
|
||||
max-height: 420px;
|
||||
overflow: auto;
|
||||
font-size: 12px;
|
||||
line-height: 1.55;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--r-sm);
|
||||
background: var(--bg-1);
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
/* 行内代码:后端说明文本用 `…` 标记字段/命令名 */
|
||||
.inline-code {
|
||||
font-family: var(--font-mono, ui-monospace, SFMono-Regular, Menlo, monospace);
|
||||
font-size: 0.92em;
|
||||
padding: 1px 5px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--r-xs);
|
||||
background: var(--bg-2);
|
||||
color: var(--text-1);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { Metadata, Viewport } from "next";
|
||||
import { AppShell } from "@/components/app-shell";
|
||||
import { SymbolNamesProvider } from "@/lib/symbols";
|
||||
import "./globals.css";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
@@ -15,7 +16,10 @@ export default function RootLayout({ children }: { children: React.ReactNode })
|
||||
return (
|
||||
<html lang="zh-CN">
|
||||
<body>
|
||||
<AppShell>{children}</AppShell>
|
||||
{/* 全站股票名称缓存:任何页面渲染代码时都能就地拿到名称 */}
|
||||
<SymbolNamesProvider>
|
||||
<AppShell>{children}</AppShell>
|
||||
</SymbolNamesProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
||||
+38
-15
@@ -14,7 +14,44 @@ interface LoadState {
|
||||
error: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 快速入口按**研究闭环的真实顺序**排列:
|
||||
* 出候选(筛选)→ 定规则(策略库)→ 验规则(回测)→ 复盘(实验对比)。
|
||||
* 顺序即引导:新用户按从左到右走一遍就是完整流程。
|
||||
*
|
||||
* ④ 之后是**归档回看**:每次回测(含同步接口调用)都会自动写一份完整归档,
|
||||
* 入口在实验列表的「打开归档」、以及回测跑完后的提示条里;归档页是只读快照,
|
||||
* 明确写出这次的**选股条件**与**交易执行依据**。
|
||||
*/
|
||||
const LINKS = [
|
||||
{
|
||||
href: "/selection",
|
||||
title: "① 股票筛选",
|
||||
desc: "因子评分 TopN 或条件选股,支持当前/历史时点,结果可解释、可查历史",
|
||||
icon: "target" as const,
|
||||
tone: "quick-card--violet",
|
||||
},
|
||||
{
|
||||
href: "/strategies",
|
||||
title: "② 策略库",
|
||||
desc: "把选股规则存成命名策略:一句话说明 + 计算公式,可一键回测、可复用对比",
|
||||
icon: "book" as const,
|
||||
tone: "quick-card--pos",
|
||||
},
|
||||
{
|
||||
href: "/backtest",
|
||||
title: "③ 选股回测",
|
||||
desc: "两级截断(候选池 n → 持仓 x)+ 择股/调仓双周期(m/y),买卖点标注在曲线上",
|
||||
icon: "gauge" as const,
|
||||
tone: "quick-card--warn",
|
||||
},
|
||||
{
|
||||
href: "/experiments",
|
||||
title: "④ 实验对比与归档",
|
||||
desc: "勾选 2~3 次回测做对比;每次回测都已完整存档,点「打开归档」可随时回看选股条件与交易执行依据",
|
||||
icon: "archive" as const,
|
||||
tone: "quick-card--violet",
|
||||
},
|
||||
{
|
||||
href: "/factors",
|
||||
title: "因子研究",
|
||||
@@ -29,20 +66,6 @@ const LINKS = [
|
||||
icon: "layers" as const,
|
||||
tone: "quick-card--pos",
|
||||
},
|
||||
{
|
||||
href: "/backtest",
|
||||
title: "选股回测",
|
||||
desc: "单因子完整回测:净值 / 回撤 / 月度收益 / 持仓",
|
||||
icon: "gauge" as const,
|
||||
tone: "quick-card--warn",
|
||||
},
|
||||
{
|
||||
href: "/selection",
|
||||
title: "股票筛选",
|
||||
desc: "因子评分 TopN 或条件选股,支持当前/历史时点,结果可解释",
|
||||
icon: "target" as const,
|
||||
tone: "quick-card--violet",
|
||||
},
|
||||
{
|
||||
href: "/signals",
|
||||
title: "交易信号",
|
||||
@@ -124,7 +147,7 @@ export default function DashboardPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Card title="快速开始" icon="spark" sub="四个研究入口,从数据到结论" flush={false}>
|
||||
<Card title="快速开始" icon="spark" sub="按 ①→④ 走一遍即是完整闭环:出候选 → 定规则 → 验规则 → 复盘微调" flush={false}>
|
||||
<div className="quick-cards" style={{ marginBottom: 0 }}>
|
||||
{LINKS.map((l) => (
|
||||
<Link key={l.href} href={l.href} className={`quick-card ${l.tone}`}>
|
||||
|
||||
@@ -4,8 +4,10 @@
|
||||
* 评分模式(多因子加权 TopN)与条件模式(结构化条件 AND);
|
||||
* 支持当前/历史 as_of;结果落库可查(selection_id),右侧展示最近选股历史。
|
||||
*/
|
||||
import Link from "next/link";
|
||||
import { useEffect, useState } from "react";
|
||||
import { apiGet, apiPost } from "@/lib/api";
|
||||
import { SymbolLink } from "@/lib/symbols";
|
||||
import { waitJob } from "@/lib/jobs";
|
||||
import type {
|
||||
FactorMeta,
|
||||
@@ -39,6 +41,22 @@ const FIELD_OPTIONS: { value: string; label: string; kind: "num" | "str" | "ref"
|
||||
];
|
||||
const OPS = ["gt", "gte", "lt", "lte", "eq", "ne", "in"];
|
||||
|
||||
/**
|
||||
* 读取后端 payload 里的 name(若存在)。
|
||||
*
|
||||
* 为什么用宽松读取而非直接 `c.name`:`lib/types.ts` 的 SelectionCandidate 目前没有
|
||||
* 声明 name 字段(该文件由其他改动维护),直接访问会类型报错;但后端可能已经返回更权威的
|
||||
* 中文名。这里只做「有且非空则用」的读取,取不到就交给 SymbolLink 渲染占位「—」,
|
||||
* 绝不编造名称(AGENT §24)。
|
||||
*/
|
||||
function payloadName(row: unknown): string | undefined {
|
||||
if (row && typeof row === "object" && "name" in row) {
|
||||
const n = (row as { name?: unknown }).name;
|
||||
if (typeof n === "string" && n.trim()) return n;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function emptyCondition(): SelectionCondition {
|
||||
return { field: "static.industry", op: "eq", value: "白酒" };
|
||||
}
|
||||
@@ -53,6 +71,14 @@ export default function SelectionPage() {
|
||||
const [conds, setConds] = useState<SelectionCondition[]>([emptyCondition()]);
|
||||
const [result, setResult] = useState<SelectionResult | null>(null);
|
||||
const [selectionId, setSelectionId] = useState("");
|
||||
/**
|
||||
* 已落库的 selection_id —— 只有「同步选股(SelectionRun.selection_id)」和「从历史列表打开(id)」两条路径才有。
|
||||
*
|
||||
* 为什么不能直接用 selectionId:异步路径(POST /selections/jobs)拿到的是 JOB id,
|
||||
* 后端不会为它写 selection 记录(backend/app/api/selections.py 只在同步接口里 selection_repo.save),
|
||||
* 拿它拼 /backtest?from_selection=<JOB id> 会让接收端 GET /api/selections/{id} 404 → 坏链接。
|
||||
*/
|
||||
const [savedSelectionId, setSavedSelectionId] = useState("");
|
||||
const [running, setRunning] = useState(false);
|
||||
const [asyncBusy, setAsyncBusy] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
@@ -101,9 +127,12 @@ export default function SelectionPage() {
|
||||
setRunning(true);
|
||||
setError("");
|
||||
setResult(null);
|
||||
// 新一轮结果尚未落库/尚未返回:先清掉旧的 selection_id,避免「结果 A + 直通到记录 B」的错配链接
|
||||
setSavedSelectionId("");
|
||||
try {
|
||||
const run = await apiPost<SelectionRun>("/selections", buildQuery());
|
||||
setSelectionId(run.selection_id);
|
||||
setSavedSelectionId(run.selection_id);
|
||||
setResult(run.result);
|
||||
apiGet<SelectionMeta[]>("/selections?limit=8").then(setHistory).catch(() => undefined);
|
||||
} catch (e) {
|
||||
@@ -118,6 +147,8 @@ export default function SelectionPage() {
|
||||
setAsyncBusy(true);
|
||||
setError("");
|
||||
setResult(null);
|
||||
// 同上:异步任务不落库选股记录,直通回测必须隐藏按钮(不给坏链接)
|
||||
setSavedSelectionId("");
|
||||
try {
|
||||
const { job_id } = await apiPost<{ job_id: string }>("/selections/jobs", buildQuery());
|
||||
const out = await waitJob<SelectionResult>(job_id);
|
||||
@@ -140,6 +171,7 @@ export default function SelectionPage() {
|
||||
try {
|
||||
const detail = await apiGet<SelectionResult>(`/selections/${id}`);
|
||||
setSelectionId(id);
|
||||
setSavedSelectionId(id);
|
||||
setResult(detail);
|
||||
} catch (e) {
|
||||
setError((e as Error).message);
|
||||
@@ -319,7 +351,7 @@ export default function SelectionPage() {
|
||||
|
||||
{!result && !running && history === null ? <SkeletonLines n={4} /> : null}
|
||||
|
||||
{result ? <ResultView result={result} /> : null}
|
||||
{result ? <ResultView result={result} selectionId={savedSelectionId} /> : null}
|
||||
|
||||
{history && history.length > 0 ? (
|
||||
<Card title="最近选股记录" icon="archive">
|
||||
@@ -343,7 +375,7 @@ export default function SelectionPage() {
|
||||
);
|
||||
}
|
||||
|
||||
function ResultView({ result }: { result: SelectionResult }) {
|
||||
function ResultView({ result, selectionId }: { result: SelectionResult; selectionId: string }) {
|
||||
const s = result.statistics;
|
||||
if (result.candidates.length === 0) {
|
||||
return (
|
||||
@@ -365,15 +397,30 @@ function ResultView({ result }: { result: SelectionResult }) {
|
||||
<Pill>范围 {s.universe_size}</Pill>
|
||||
<Pill>评分 {s.evaluated}</Pill>
|
||||
<Pill tone="pos">选出 {s.selected}</Pill>
|
||||
{/* 选股 → 回测直通:只带「已落库」的 selection_id;没有它(如异步任务未落库)就不渲染按钮,避免坏链接 */}
|
||||
{selectionId ? (
|
||||
<Link
|
||||
href={`/backtest?from_selection=${encodeURIComponent(selectionId)}`}
|
||||
className="btn btn--sm"
|
||||
title="把这次的规则(条件/因子/TopN/口径)预填到回测页;回测会在每个择股日按同一规则重新选股,不是固定持有这批股票"
|
||||
>
|
||||
<span>按此条件回测</span>
|
||||
</Link>
|
||||
) : null}
|
||||
</>
|
||||
}
|
||||
>
|
||||
{selectionId ? (
|
||||
<div className="hint" style={{ marginBottom: 10 }}>
|
||||
按此条件回测:把这次的规则(条件 / 因子 / TopN / 口径)预填到回测页;回测会在每个择股日按同一规则重新选股,<b>不是固定持有这批股票</b>。
|
||||
</div>
|
||||
) : null}
|
||||
<div className="table-wrap">
|
||||
<table className="tbl">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>#</th>
|
||||
<th>代码</th>
|
||||
<th>代码 / 名称</th>
|
||||
<th>得分</th>
|
||||
<th>因子值</th>
|
||||
<th>入选理由</th>
|
||||
@@ -383,8 +430,9 @@ function ResultView({ result }: { result: SelectionResult }) {
|
||||
{result.candidates.map((c) => (
|
||||
<tr key={c.symbol}>
|
||||
<td className="mono dim">{c.rank}</td>
|
||||
<td className="mono">
|
||||
<b>{c.symbol}</b>
|
||||
<td>
|
||||
{/* 后端 payload 若已带 name 就用它(更权威),否则回落到全局名称表 */}
|
||||
<SymbolLink symbol={c.symbol} name={payloadName(c)} />
|
||||
</td>
|
||||
<td className="mono">{c.score.toFixed(4)}</td>
|
||||
<td style={{ fontSize: 12 }}>
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
*/
|
||||
import { useEffect, useState } from "react";
|
||||
import { apiGet, apiPost } from "@/lib/api";
|
||||
import { SymbolLink } from "@/lib/symbols";
|
||||
import type {
|
||||
FactorMeta,
|
||||
SelectionQuery,
|
||||
@@ -23,6 +24,21 @@ import {
|
||||
SkeletonLines,
|
||||
} from "@/components/ui";
|
||||
|
||||
/**
|
||||
* 读取后端 payload 里的 name(若存在)。
|
||||
*
|
||||
* 为什么用宽松读取而非直接 `e.name`:`lib/types.ts` 的 SignalEvent 目前没有声明 name
|
||||
* 字段(该文件由其他改动维护);后端可能已返回更权威的中文名。取不到就交给 SymbolLink
|
||||
* 渲染占位「—」,不编造名称(AGENT §24)。
|
||||
*/
|
||||
function payloadName(row: unknown): string | undefined {
|
||||
if (row && typeof row === "object" && "name" in row) {
|
||||
const n = (row as { name?: unknown }).name;
|
||||
if (typeof n === "string" && n.trim()) return n;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export default function SignalsPage() {
|
||||
const [factors, setFactors] = useState<FactorMeta[]>([]);
|
||||
const [factor, setFactor] = useState("momentum_60");
|
||||
@@ -165,7 +181,7 @@ function ResultView({ result }: { result: SignalResult }) {
|
||||
<thead>
|
||||
<tr>
|
||||
<th>类型</th>
|
||||
<th>代码</th>
|
||||
<th>代码 / 名称</th>
|
||||
<th>得分</th>
|
||||
<th>价格</th>
|
||||
<th>触发理由</th>
|
||||
@@ -179,7 +195,7 @@ function ResultView({ result }: { result: SignalResult }) {
|
||||
{e.signal_type}
|
||||
</Pill>
|
||||
</td>
|
||||
<td className="mono"><b>{e.symbol}</b></td>
|
||||
<td><SymbolLink symbol={e.symbol} name={payloadName(e)} /></td>
|
||||
<td className="mono">{e.score?.toFixed(4)}</td>
|
||||
<td className="mono">{e.price ?? "-"}</td>
|
||||
<td style={{ fontSize: 12 }}>
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useParams } from "next/navigation";
|
||||
import { apiGet } from "@/lib/api";
|
||||
import { SymbolLink } from "@/lib/symbols";
|
||||
import type { ChartMarker, ChartResult, Stock } from "@/lib/types";
|
||||
import {
|
||||
PageHeader,
|
||||
@@ -19,7 +20,7 @@ import {
|
||||
SkeletonLines,
|
||||
} from "@/components/ui";
|
||||
import type { CandleDatum } from "@/components/StockChart/chartTypes";
|
||||
import { StockChart, type ChartLibrary } from "@/components/StockChart/StockChart";
|
||||
import { StockChart } from "@/components/StockChart/StockChart";
|
||||
|
||||
export default function StockResearchPage() {
|
||||
const params = useParams<{ symbol: string }>();
|
||||
@@ -29,7 +30,6 @@ export default function StockResearchPage() {
|
||||
const [signalMarkers, setSignalMarkers] = useState<ChartMarker[]>([]);
|
||||
const [selectionMarkers, setSelectionMarkers] = useState<ChartMarker[]>([]);
|
||||
const [adjust, setAdjust] = useState<"none" | "qfq" | "hfq">("none");
|
||||
const [chartLib, setChartLib] = useState<ChartLibrary>("lightweight");
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
@@ -77,16 +77,11 @@ export default function StockResearchPage() {
|
||||
title={stock ? `${stock.name} ${stock.symbol}` : symbol}
|
||||
sub={
|
||||
stock
|
||||
? `行业 ${stock.industry ?? "-"} · 市场 ${stock.market ?? "-"} · 上市 ${stock.list_date} · 状态 ${stock.status}`
|
||||
? `名称 ${stock.name} · 行业 ${stock.industry ?? "-"} · 市场 ${stock.market ?? "-"} · 上市 ${stock.list_date} · 状态 ${stock.status}`
|
||||
: "个股研究视图(K 线 / 信号 / 成交)"
|
||||
}
|
||||
actions={
|
||||
<div className="row" style={{ gap: 6 }}>
|
||||
{(["echarts", "lightweight"] as const).map((l) => (
|
||||
<Btn key={l} variant={chartLib === l ? "primary" : undefined} onClick={() => setChartLib(l)}>
|
||||
{l === "echarts" ? "ECharts" : "Lightweight"}
|
||||
</Btn>
|
||||
))}
|
||||
{(["none", "qfq", "hfq"] as const).map((m) => (
|
||||
<Btn
|
||||
key={m}
|
||||
@@ -107,7 +102,7 @@ export default function StockResearchPage() {
|
||||
<>
|
||||
<Card
|
||||
icon="chartLine"
|
||||
title={`K 线 · ${chart?.metadata.adjust_mode ?? "-"} · ${chart?.metadata.name ?? ""}`}
|
||||
title={`K 线 · ${chart?.metadata.adjust_mode ?? "-"} · ${chart?.metadata.name ?? stock?.name ?? ""}`}
|
||||
tools={
|
||||
<>
|
||||
<Pill>共 {chart?.metadata.bar_count ?? 0} 根</Pill>
|
||||
@@ -121,13 +116,12 @@ export default function StockResearchPage() {
|
||||
<Empty icon="chartLine" title="无 K 线数据" hint="该区间/口径没有行情(可能停牌或未同步)。" />
|
||||
) : (
|
||||
<StockChart
|
||||
library={chartLib}
|
||||
data={candles}
|
||||
volume={chart?.volume ?? []}
|
||||
indicators={chart?.indicators ?? {}}
|
||||
fills={chart?.fills ?? []}
|
||||
signals={chart?.signals ?? []}
|
||||
name={symbol}
|
||||
name={stock ? `${stock.symbol} ${stock.name}` : symbol}
|
||||
/>
|
||||
)}
|
||||
</Card>
|
||||
@@ -135,7 +129,12 @@ export default function StockResearchPage() {
|
||||
{chart && (chart.signals.length || chart.fills.length) ? (
|
||||
<Card
|
||||
icon="scale"
|
||||
title={`回测成交与信号 · ${symbol}`}
|
||||
title={
|
||||
<>
|
||||
回测成交与信号 ·{" "}
|
||||
<SymbolLink symbol={symbol} name={stock?.name} plain />
|
||||
</>
|
||||
}
|
||||
tools={
|
||||
<>
|
||||
<Pill tone="pos">成交 {chart.fills.length}</Pill>
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { apiGet } from "@/lib/api";
|
||||
import { SymbolLink } from "@/lib/symbols";
|
||||
import type { Stock } from "@/lib/types";
|
||||
import {
|
||||
PageHeader,
|
||||
@@ -118,8 +118,7 @@ export default function StocksPage() {
|
||||
<table className="tbl">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>代码</th>
|
||||
<th>名称</th>
|
||||
<th>代码 / 名称</th>
|
||||
<th>市场</th>
|
||||
<th>行业</th>
|
||||
<th>地区</th>
|
||||
@@ -130,8 +129,8 @@ export default function StocksPage() {
|
||||
<tbody>
|
||||
{stocks.map((s) => (
|
||||
<tr key={s.symbol}>
|
||||
<td className="cell-mono cell-strong"><Link href={`/stocks/${s.symbol}`} style={{ textDecoration: "none" }}>{s.symbol}</Link></td>
|
||||
<td>{s.name}</td>
|
||||
{/* 代码列直接合并名称(后端 payload 的 name 更权威),避免与独立名称列重复展示 */}
|
||||
<td><SymbolLink symbol={s.symbol} name={s.name} /></td>
|
||||
<td>
|
||||
<span className="tag tag--mono">{marketOf(s.symbol)}</span>
|
||||
</td>
|
||||
|
||||
@@ -0,0 +1,518 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* 策略库(/strategies)—— 策略的命名资产中心。
|
||||
*
|
||||
* 职责:
|
||||
* - 列表 + 检索:每个策略显示**一句话说明**与**计算公式**(后端 describe_strategy 推导)。
|
||||
* - 新建 / 编辑:复用 StrategyParamsForm(与回测页同一份参数模型与校验)。
|
||||
* - 一键回测:`POST /strategies/{id}/expand`(补 period + 资金)→ `POST /jobs` → 轮询 →
|
||||
* 就地展示核心指标,并提供「查看实验详情 / 回到回测页看曲线」。
|
||||
* - 去回测页:`/backtest?strategy={id}`(回测页载入后可保存为策略、可微调)。
|
||||
*
|
||||
* 设计取舍:编辑不弹窗而是就地展开表单(页面滚动位置不丢,参数多,弹窗太挤)。
|
||||
*/
|
||||
import Link from "next/link";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { apiDelete, apiGet, apiPost, apiPut } from "@/lib/api";
|
||||
import { STAGE_LABEL, submitJob, waitJob, type JobOutcome } from "@/lib/jobs";
|
||||
import { recentRange } from "@/lib/dates";
|
||||
import type { BacktestResult, FactorMeta, StrategyDefinition } from "@/lib/types";
|
||||
import {
|
||||
Btn,
|
||||
Banner,
|
||||
Card,
|
||||
Empty,
|
||||
Loading,
|
||||
PageHeader,
|
||||
Pill,
|
||||
Progress,
|
||||
BacktestMetrics,
|
||||
Field,
|
||||
} from "@/components/ui";
|
||||
import {
|
||||
StrategyParamsForm,
|
||||
casePreset,
|
||||
emptyParams,
|
||||
paramsFromStrategy,
|
||||
strategyFromParams,
|
||||
validateParams,
|
||||
type StrategyParams,
|
||||
} from "@/components/StrategyParamsForm";
|
||||
import { StrategyDocBody, StrategyDocCard } from "@/components/StrategyDocCard";
|
||||
import { useStrategyDoc, useStrategyDocById } from "@/lib/strategy";
|
||||
|
||||
export default function StrategiesPage() {
|
||||
const [list, setList] = useState<StrategyDefinition[] | null>(null);
|
||||
const [factors, setFactors] = useState<FactorMeta[]>([]);
|
||||
const [query, setQuery] = useState("");
|
||||
const [err, setErr] = useState("");
|
||||
const [msg, setMsg] = useState("");
|
||||
const [editing, setEditing] = useState<StrategyParams | null>(null);
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [docId, setDocId] = useState<string | null>(null);
|
||||
|
||||
const range = useMemo(() => recentRange(), []);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const rows = await apiGet<StrategyDefinition[]>("/strategies");
|
||||
setList(rows);
|
||||
setErr("");
|
||||
} catch (e) {
|
||||
setErr((e as Error).message);
|
||||
setList([]);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
let alive = true;
|
||||
apiGet<FactorMeta[]>("/factors")
|
||||
.then((f) => alive && setFactors(f))
|
||||
.catch(() => alive && setFactors([]));
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, [load]);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
if (!list) return null;
|
||||
const q = query.trim().toLowerCase();
|
||||
if (!q) return list;
|
||||
return list.filter((s) => {
|
||||
const hay = [
|
||||
s.name,
|
||||
s.description,
|
||||
(s.factors ?? []).map((f) => f.name).join(" "),
|
||||
(s.conditions ?? []).map((c) => c.field).join(" "),
|
||||
]
|
||||
.join(" ")
|
||||
.toLowerCase();
|
||||
return hay.includes(q);
|
||||
});
|
||||
}, [list, query]);
|
||||
|
||||
/* ---------- 新建 / 编辑 ---------- */
|
||||
function startCreate() {
|
||||
setEditingId(null);
|
||||
setEditing(emptyParams(range));
|
||||
setDocId(null);
|
||||
setMsg("");
|
||||
setErr("");
|
||||
}
|
||||
|
||||
function startEdit(s: StrategyDefinition) {
|
||||
setEditingId(s.id ?? null);
|
||||
setEditing(paramsFromStrategy(s, { ...emptyParams(range) }));
|
||||
setDocId(s.id ?? null);
|
||||
setMsg("");
|
||||
setErr("");
|
||||
}
|
||||
|
||||
async function save() {
|
||||
if (!editing) return;
|
||||
const errors = validateParams(editing, { requireMeta: true });
|
||||
if (Object.keys(errors).length) {
|
||||
setErr(Object.values(errors)[0]);
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
setErr("");
|
||||
try {
|
||||
const body = strategyFromParams(editing, editingId ?? undefined);
|
||||
if (editingId) {
|
||||
await apiPut<StrategyDefinition>(`/strategies/${encodeURIComponent(editingId)}`, body);
|
||||
setMsg(`已更新策略「${body.name}」`);
|
||||
} else {
|
||||
const saved = await apiPost<StrategyDefinition>("/strategies", body);
|
||||
setMsg(`已保存策略「${saved.name}」(${saved.id})`);
|
||||
}
|
||||
setEditing(null);
|
||||
setEditingId(null);
|
||||
setDocId(null);
|
||||
await load();
|
||||
} catch (e) {
|
||||
setErr((e as Error).message);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function remove(s: StrategyDefinition) {
|
||||
if (!s.id) return;
|
||||
if (!window.confirm(`删除策略「${s.name}」?此操作不可撤销(已跑过的实验不受影响)。`)) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
await apiDelete(`/strategies/${encodeURIComponent(s.id)}`);
|
||||
setMsg(`已删除策略「${s.name}」`);
|
||||
await load();
|
||||
} catch (e) {
|
||||
setErr((e as Error).message);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
title="策略库"
|
||||
sub="把调好的参数存成命名策略:每个策略都有「一句话说明 + 计算公式 + 执行步骤」,可一键回测、可复现、可对比。"
|
||||
actions={
|
||||
<div className="row" style={{ gap: 8 }}>
|
||||
<Link href="/experiments" className="btn">
|
||||
<span>查看实验</span>
|
||||
</Link>
|
||||
<Btn variant="primary" icon="plus" onClick={startCreate} disabled={busy}>
|
||||
新建策略
|
||||
</Btn>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
{err ? <Banner tone="error">{err}</Banner> : null}
|
||||
{msg ? <Banner tone="info">{msg}</Banner> : null}
|
||||
|
||||
{/* ---------- 编辑器 ---------- */}
|
||||
{editing ? (
|
||||
<Card
|
||||
icon={editingId ? "edit" : "plus"}
|
||||
title={editingId ? `编辑策略 · ${editingId}` : "新建策略"}
|
||||
tools={
|
||||
<div className="row" style={{ gap: 8 }}>
|
||||
<Btn onClick={() => { setEditing(null); setEditingId(null); setDocId(null); }} disabled={busy}>
|
||||
取消
|
||||
</Btn>
|
||||
<Btn variant="primary" icon="check" loading={busy} onClick={save} disabled={busy}>
|
||||
{editingId ? "保存修改" : "保存到策略库"}
|
||||
</Btn>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<StrategyParamsForm
|
||||
value={editing}
|
||||
onChange={setEditing}
|
||||
factorOptions={factors}
|
||||
showMeta
|
||||
showPeriod={false}
|
||||
disabled={busy}
|
||||
errors={validateParams(editing, { requireMeta: true })}
|
||||
/>
|
||||
<div className="hint" style={{ marginTop: 10 }}>
|
||||
策略只保存「怎么选股/怎么调仓/怎么收费」,**不含回测区间与初始资金** ——
|
||||
这两项在运行回测时才指定,因此同一策略可用于不同区间的复现与对比。
|
||||
</div>
|
||||
<div className="row" style={{ marginTop: 12, gap: 8 }}>
|
||||
<Btn
|
||||
icon="target"
|
||||
disabled={busy}
|
||||
onClick={() =>
|
||||
setEditing({
|
||||
...casePreset(range),
|
||||
name: editing.name,
|
||||
description: editing.description,
|
||||
})
|
||||
}
|
||||
>
|
||||
载入高股息案例默认参数
|
||||
</Btn>
|
||||
</div>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
{editing ? (
|
||||
<EditingDoc params={editing} savedId={editingId} />
|
||||
) : null}
|
||||
|
||||
{docId && !editing ? <SavedDoc id={docId} onClose={() => setDocId(null)} /> : null}
|
||||
|
||||
{/* ---------- 列表 ---------- */}
|
||||
<Card
|
||||
icon="archive"
|
||||
title={`已保存策略${list ? ` · ${list.length}` : ""}`}
|
||||
tools={
|
||||
<input
|
||||
className="input"
|
||||
style={{ width: 220 }}
|
||||
placeholder="搜索策略名 / 说明 / 因子 / 条件"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
aria-label="搜索策略"
|
||||
/>
|
||||
}
|
||||
>
|
||||
{filtered === null ? (
|
||||
<Loading label="读取策略库…" />
|
||||
) : filtered.length === 0 ? (
|
||||
<Empty
|
||||
icon="archive"
|
||||
title={list && list.length ? "没有匹配的策略" : "策略库还是空的"}
|
||||
hint={
|
||||
list && list.length
|
||||
? "换个关键词试试。"
|
||||
: "点右上角「新建策略」保存第一个策略;也可以先去回测页调好参数,再点「保存为策略」。"
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<div className="strategy-grid">
|
||||
{filtered.map((s) => (
|
||||
<StrategyCard
|
||||
key={s.id ?? s.name}
|
||||
s={s}
|
||||
range={range}
|
||||
busy={busy}
|
||||
onEdit={() => startEdit(s)}
|
||||
onDelete={() => remove(s)}
|
||||
onDoc={() => setDocId(s.id ?? null)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
/** 未保存参数的实时说明预览 */
|
||||
function EditingDoc({ params, savedId }: { params: StrategyParams; savedId: string | null }) {
|
||||
const live = useStrategyDoc(params, !savedId);
|
||||
const saved = useStrategyDocById(savedId);
|
||||
const state = savedId ? saved : live;
|
||||
return (
|
||||
<StrategyDocCard
|
||||
doc={state.doc}
|
||||
loading={state.loading}
|
||||
error={state.error}
|
||||
title="策略说明与计算公式(随参数实时更新)"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SavedDoc({ id, onClose }: { id: string; onClose: () => void }) {
|
||||
const state = useStrategyDocById(id);
|
||||
return (
|
||||
<Card
|
||||
icon="book"
|
||||
title={`策略说明 · ${id}`}
|
||||
tools={<Btn size="sm" onClick={onClose}>收起</Btn>}
|
||||
>
|
||||
{state.doc ? (
|
||||
<StrategyDocBody doc={state.doc} />
|
||||
) : (
|
||||
<div className="hint">
|
||||
{state.loading
|
||||
? "正在生成说明与公式…"
|
||||
: `说明暂不可用${state.error ? `:${state.error}` : "(后端 /api/strategies/{id}/describe 未就绪)"}。`}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
/** 单个策略卡片:说明 + 参数摘要 + 一键回测 */
|
||||
function StrategyCard({
|
||||
s,
|
||||
range,
|
||||
busy,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onDoc,
|
||||
}: {
|
||||
s: StrategyDefinition;
|
||||
range: { start: string; end: string };
|
||||
busy: boolean;
|
||||
onEdit: () => void;
|
||||
onDelete: () => void;
|
||||
onDoc: () => void;
|
||||
}) {
|
||||
const [start, setStart] = useState("2020-01-01");
|
||||
const [end, setEnd] = useState(range.end);
|
||||
const [capital, setCapital] = useState(1_000_000);
|
||||
const [running, setRunning] = useState(false);
|
||||
const [jobId, setJobId] = useState("");
|
||||
const [result, setResult] = useState<BacktestResult | null>(null);
|
||||
const [stage, setStage] = useState("");
|
||||
const [expId, setExpId] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const sel = s.selection ?? {};
|
||||
const costs = s.costs ?? {};
|
||||
|
||||
async function run() {
|
||||
if (!s.id) return;
|
||||
if (start >= end) {
|
||||
setError("开始日期必须早于结束日期");
|
||||
return;
|
||||
}
|
||||
setRunning(true);
|
||||
setError("");
|
||||
setResult(null);
|
||||
setExpId("");
|
||||
try {
|
||||
const spec = await apiPost<Record<string, unknown>>(
|
||||
`/strategies/${encodeURIComponent(s.id)}/expand`,
|
||||
{ period: [start, end], initial_capital: capital }
|
||||
);
|
||||
const { job_id } = await submitJob(spec as never);
|
||||
setJobId(job_id);
|
||||
setStage("queued");
|
||||
const out: JobOutcome<BacktestResult> = await waitJob<BacktestResult>(job_id, 900_000, (info) =>
|
||||
setStage(info.stage)
|
||||
);
|
||||
if (out.status === "success" && out.result) {
|
||||
setResult(out.result);
|
||||
setExpId(out.experimentId ?? "");
|
||||
} else {
|
||||
setError(`任务${out.status}${out.error ? `:${out.error}` : ""}`);
|
||||
}
|
||||
} catch (e) {
|
||||
setError((e as Error).message);
|
||||
} finally {
|
||||
setRunning(false);
|
||||
setJobId("");
|
||||
setStage("");
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="card" style={{ display: "flex", flexDirection: "column", gap: 10 }}>
|
||||
<div className="between">
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<div className="card__title" style={{ marginBottom: 2 }}>
|
||||
{s.name}
|
||||
</div>
|
||||
<div className="row" style={{ gap: 6, flexWrap: "wrap" }}>
|
||||
<span className="mono hint">{s.id}</span>
|
||||
{s.created_at ? <span className="hint">创建 {String(s.created_at).slice(0, 10)}</span> : null}
|
||||
</div>
|
||||
</div>
|
||||
<Pill tone="violet" icon="target">
|
||||
{s.factors?.map((f) => f.name).join(" + ") || "—"}
|
||||
</Pill>
|
||||
</div>
|
||||
|
||||
<div className="strategy-desc">{s.description || "(无说明:建议补一句话说明,便于日后识别)"}</div>
|
||||
|
||||
<div className="chips">
|
||||
<span className="chip">
|
||||
候选池 <b>{sel.top_n ?? "—"}</b> → 持仓 <b>{sel.hold_top_x ?? sel.top_n ?? "—"}</b>
|
||||
</span>
|
||||
<span className="chip">
|
||||
择股 <b>{s.selection_interval_months ?? "跟随 y"}</b> 月 / 调仓{" "}
|
||||
<b>{s.rebalance_interval_months ?? "跟随 m"}</b> 月
|
||||
</span>
|
||||
<span className="chip">
|
||||
复权 <b>{adjLabel(s.price_adjustment)}</b>
|
||||
</span>
|
||||
<span className="chip">
|
||||
{s.universe?.exclude_st ? "剔除 ST" : "含 ST"} · 费率{" "}
|
||||
<b>{((costs.commission_rate ?? 0) * 100).toFixed(3)}%</b> + 印花{" "}
|
||||
<b>{((costs.stamp_tax_rate ?? 0) * 100).toFixed(2)}%</b>
|
||||
</span>
|
||||
{(s.conditions ?? []).length ? (
|
||||
<span className="chip">
|
||||
条件 <b>{(s.conditions ?? []).map((c) => `${c.field} ${opLabel(c.op)} ${c.value ?? ""}`).join(" 且 ")}</b>
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="strategy-actions">
|
||||
<Btn size="sm" variant="primary" icon="play" loading={running} disabled={running || busy} onClick={run}>
|
||||
{running ? "后台运行中…" : "一键回测"}
|
||||
</Btn>
|
||||
<Link href={`/backtest?strategy=${encodeURIComponent(s.id ?? "")}`} className="btn btn--sm">
|
||||
<span>载入回测页(可微调)</span>
|
||||
</Link>
|
||||
<Btn size="sm" icon="book" onClick={onDoc} disabled={busy}>
|
||||
看说明/公式
|
||||
</Btn>
|
||||
<Btn size="sm" icon="edit" onClick={onEdit} disabled={busy}>
|
||||
编辑
|
||||
</Btn>
|
||||
<Btn size="sm" variant="danger" icon="trash" onClick={onDelete} disabled={busy}>
|
||||
删除
|
||||
</Btn>
|
||||
</div>
|
||||
|
||||
{/* 运行区间(默认 2020-01-01 ~ 最近交易日) */}
|
||||
<details open={open} onToggle={(e) => setOpen((e.target as HTMLDetailsElement).open)}>
|
||||
<summary className="hint" style={{ cursor: "pointer" }}>
|
||||
回测区间与资金(一键回测使用)
|
||||
</summary>
|
||||
<div className="form-grid" style={{ marginTop: 8 }}>
|
||||
<Field label="开始日期">
|
||||
<input type="date" className="input" value={start} onChange={(e) => setStart(e.target.value)} disabled={running} />
|
||||
</Field>
|
||||
<Field label="结束日期">
|
||||
<input type="date" className="input" value={end} onChange={(e) => setEnd(e.target.value)} disabled={running} />
|
||||
</Field>
|
||||
<Field label="初始资金(元)">
|
||||
<input
|
||||
type="number"
|
||||
className="input"
|
||||
step={100000}
|
||||
min={10000}
|
||||
value={capital}
|
||||
onChange={(e) => setCapital(Number(e.target.value))}
|
||||
disabled={running}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
{running ? (
|
||||
<Progress
|
||||
value={35}
|
||||
label={
|
||||
<>
|
||||
任务 <span className="mono">{jobId || "排队中…"}</span> 后台执行中(全市场回测通常 1~5 分钟)
|
||||
</>
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
{error ? <Banner tone="error">{error}</Banner> : null}
|
||||
|
||||
{result ? (
|
||||
<div>
|
||||
<div className="between" style={{ marginBottom: 8 }}>
|
||||
<Pill tone="pos" icon="check">
|
||||
回测完成
|
||||
</Pill>
|
||||
<Link
|
||||
href={expId ? `/experiments?exp=${encodeURIComponent(expId)}` : "/experiments"}
|
||||
className="btn btn--sm"
|
||||
>
|
||||
<span>在实验中对比</span>
|
||||
</Link>
|
||||
{expId ? (
|
||||
<>
|
||||
<Link href={`/experiments/${encodeURIComponent(expId)}`} className="btn btn--sm">
|
||||
<span>打开归档(完整快照)</span>
|
||||
</Link>
|
||||
<Link href={`/backtest?from_experiment=${encodeURIComponent(expId)}`} className="btn btn--sm">
|
||||
<span>以此参数再跑</span>
|
||||
</Link>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
<BacktestMetrics s={result.summary} />
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function adjLabel(m?: string): string {
|
||||
return m === "hfq" ? "后复权" : m === "qfq" ? "前复权" : "不复权";
|
||||
}
|
||||
|
||||
function opLabel(op: string): string {
|
||||
return (
|
||||
{ gt: ">", gte: "≥", lt: "<", lte: "≤", eq: "=", ne: "≠", in: "属于", not_in: "不属于" }[op] ?? op
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user