Files
qlib/frontend/web/app/experiments/[id]/page.tsx
T
Simon f13b34c59e feat(web): 字段库页 + 界面单位换算 + 因子目录参数化 + 选股策略条件表单
字段库与单位:
- `/fields`:字段库管理页(中文名/说明可改、可停用;kind 是引擎事实不可改);
  单位只在字段自己的阶梯里选(总市值 = 万元/亿元),越界 422 原样展示。
- `lib/units.ts`:界面单位 ⇄ 基准单位换算集中一处,条件输入按界面单位回显、
  提交前换回基准单位(引擎只认基准单位,库里存的也永远是基准单位)。
- `SelectionStrategyForm` 取代 `StrategyParamsForm`:一个策略只定义「怎么选」
  (股票池 + 因子 + 过滤条件),条件字段来自字段库接口而不是前端硬编码枚举。

因子参数化(名字即身份,界面不许藏):
- `/factors` 新增「参数」列与展开行:精确引擎键、每个参数的允许范围、来源、依赖列
  (依赖列标明「引擎事实,不可改」);说明文案从「不可修改」改为
  「改参数 = 新建参数化因子 = 新身份,旧因子/既有策略不变义」。
- 「新建参数化因子」卡片:模板下拉 + 受控窗口(min/max)+ 方向枚举下拉,
  实时预览规范键/中文名/渲染公式;后端 422 的原文原样展示,不静默截断。
- 因子下拉显示中文名(含参数)与短键,**value 一律是完整引擎键**;
  停用的因子从各页候选里消失(/strategies、/selection、/signals、/factors/compose),
  既有策略/归档仍按名字解析;`/factors/compose` 顺手修了勾选框点击目标过小。
- `/fields` 加提示:字段库只列内置因子名,参数化因子在 /factors 管理,
  选股策略条件下拉里会一并出现。

tsc --noEmit 0 error;verify_ui_alignment 8 页 160 项全过(另跑 3 个选因子页 51/54)。
2026-10-01 16:38:29 +08:00

692 lines
29 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 归档详情页(`/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 { BacktestCombo, BacktestResult, 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 rawSpec = (detail.spec ?? null) as Record<string, unknown> | null;
// 组合回测的归档:spec_json 是 BacktestCombo(含 strategy_ids/hold_count),不是 ResearchSpec。
// 用同一套「选股条件/交易执行依据」卡去解读它会满屏「未记录」—— 那是谎报。
// 这里识别出来交给专门的 ComboArchiveCards;成本/复权从 result.config_snapshot 取快照。
const isComboArchive =
detail.kind === "backtest" &&
!!rawSpec &&
(Array.isArray(rawSpec.strategy_ids) || "hold_count" in rawSpec);
const result = detail.result;
const spec = isComboArchive ? null : (rawSpec as Partial<ResearchSpec> | null);
const comboSpec = isComboArchive ? (rawSpec as unknown as BacktestCombo) : null;
const comboCosts = isComboArchive
? ((result as BacktestResult | null)?.config_snapshot as Record<string, unknown> | undefined)
: undefined;
// 关键字段计数按 kind 给不同口径(用回测字段去数因子/选股结果是错的)
const fieldSummary = archivedFieldSummary(detail.kind, result);
// 与引擎执行规则同源的说明(后端按归档 spec 推导;推导失败时如实显示错误)
// 只有回测归档才需要「完整说明与计算公式」:describe 推导的是回测策略口径,
// 因子测试/选股用同一 spec 结构但规则并未执行,拉回来展示等于误导(且白花一次请求)。
const doc = spec && detail.kind === "backtest" && !isComboArchive
? 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 ?? []} combo={comboSpec} comboCosts={comboCosts} />
{detail.kind === "backtest" && !isComboArchive ? (
<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`)—— 把这些字段按回测口径展示,
* 会让人以为这次跑过调仓与撮合。所以这里只列生效项,并显式说明哪些字段未生效。
*/
/**
* 组合回测归档的 spec 卡:如实展示「引用了哪些选股策略 + 回测参数 + 成本/复权快照」。
*
* 关键点(诚实性,AGENT §7/§24):组合归档的 spec_json 是 BacktestCombo,不含
* factors/selection/costs;成本与复权在运行时从公共配置快照进了 result.config_snapshot,
* 这里从 `costs`(即 config_snapshot)读取并明确标注「来自运行时的公共配置快照」。
*/
function ComboArchiveCards({
combo,
costs,
}: {
combo: BacktestCombo;
costs?: Record<string, unknown>;
}) {
const costObj = (costs?.costs ?? {}) as Record<string, number>;
const adj = String(costs?.price_adjustment ?? "未记录");
const freqLabel =
combo.rebalance_freq === "daily" ? "每日" : combo.rebalance_freq === "weekly" ? "每周" : "每月";
return (
<Card
icon="layers"
title="回测组合(本次实际使用的规则)"
tools={<Pill tone="violet">来自归档 spec + 运行时配置快照</Pill>}
>
<div className="spec-grid">
<SpecBlock title="引用的选股策略">
{(combo.strategy_ids ?? []).length === 0 ? (
<SpecRow k="策略" v="—" />
) : (
(combo.strategy_ids ?? []).map((id) => (
<SpecRow key={id} k={id} v="选股条件组合(详见策略库)" mono />
))
)}
<SpecRow k="打分方式" v="多策略取并集后 Borda 秩和统一排序" />
</SpecBlock>
<SpecBlock title="回测参数">
<SpecRow k="持仓数 N" v={String(combo.hold_count ?? "—")} />
<SpecRow k="持仓天数区间" v={`[${combo.hold_min_days ?? 0}, ${combo.hold_max_days ?? "∞"}] 天`} />
<SpecRow k="调仓时机" v={freqLabel} />
<SpecRow k="起始资金" v={`${Number(combo.initial_capital ?? 0).toLocaleString()} 元`} />
<SpecRow k="回测区间" v={combo.period ? `${combo.period[0]} ~ ${combo.period[1]}` : "—"} mono />
</SpecBlock>
<SpecBlock title="交易执行依据(来自运行时的公共配置快照)">
<SpecRow k="佣金" v={costObj.commission_rate != null ? `${(costObj.commission_rate * 100).toFixed(3)}%(买卖各一次)` : "未记录"} />
<SpecRow k="印花税" v={costObj.stamp_tax_rate != null ? `${(costObj.stamp_tax_rate * 100).toFixed(2)}%(仅卖出)` : "未记录"} />
<SpecRow k="滑点" v={costObj.slippage_rate != null ? `${(costObj.slippage_rate * 100).toFixed(2)}%` : "未记录"} />
<SpecRow k="最低佣金" v={costObj.min_commission != null ? `${costObj.min_commission} 元/笔` : "未启用"} />
<SpecRow k="复权口径" v={adj === "hfq" ? "后复权 hfq" : adj === "qfq" ? "前复权 qfq" : adj === "none" ? "不复权" : adj} />
<SpecRow k="成交时点" v="调仓日收盘(调仓在当日收盘生效,自次日起计收益)" />
<SpecRow k="Tmax 强制了结" v="每个交易日检查;超过最长持仓天数即卖出" />
<SpecRow k="Tmin 保护" v="掉出 TopN 但未满最短持仓天数时暂不卖(防频繁换手)" />
</SpecBlock>
</div>
<div className="hint" style={{ marginTop: 10 }}>
<RichText text="这是一次**回测组合**归档:它引用了上面的选股策略(「怎么选」),并用这里的回测参数(资金/持仓/持仓天数区间/调仓时机)执行。成本与复权是**运行那一刻从公共配置快照**下来的,所以即使之后改了公共配置,本归档的数字也不会变 —— 这正是可复现的依据。" />
</div>
</Card>
);
}
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,
combo,
comboCosts,
}: {
detail: ExperimentDetail;
spec: Partial<ResearchSpec> | null;
factors: FactorMeta[];
combo?: BacktestCombo | null;
comboCosts?: Record<string, unknown>;
}) {
// 组合回测归档:spec 是 BacktestCombo,用专属卡片如实展示(引用的策略 + 回测参数 +
// 从 config_snapshot 取的成本/复权快照)。绝不能套单策略回测的「选股条件/交易执行依据」。
if (combo) {
return <ComboArchiveCards combo={combo} costs={comboCosts} />;
}
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(/\.$/, "")}%`;
}