字段库与单位: - `/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)。
692 lines
29 KiB
TypeScript
692 lines
29 KiB
TypeScript
/**
|
||
* 归档详情页(`/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(/\.$/, "")}%`;
|
||
} |