feat(web): 字段库页 + 界面单位换算 + 因子目录参数化 + 选股策略条件表单

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

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

tsc --noEmit 0 error;verify_ui_alignment 8 页 160 项全过(另跑 3 个选因子页 51/54)。
This commit is contained in:
Simon
2026-10-01 16:38:29 +08:00
parent 2e90f3eeac
commit f13b34c59e
19 changed files with 2982 additions and 1521 deletions
+87 -5
View File
@@ -27,7 +27,7 @@ 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";
import type { BacktestCombo, BacktestResult, ExperimentDetail, FactorMeta, ResearchCondition, ResearchSpec, StrategyDoc } from "@/lib/types";
export const dynamic = "force-dynamic"; // 归档可能被删除/新增,禁止静态化缓存
@@ -74,14 +74,26 @@ export default async function ExperimentArchivePage({
// 归档不存在 → 交给 Next 的 404(比渲染一个空壳页更诚实)
if (!detail) notFound();
const spec = (detail.spec ?? null) as Partial<ResearchSpec> | null;
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"
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;
@@ -95,9 +107,9 @@ export default async function ExperimentArchivePage({
<ArchiveActions id={detail.id} spec={detail.spec} />
<ArchivedSpecCards detail={detail} spec={spec} factors={factors ?? []} />
<ArchivedSpecCards detail={detail} spec={spec} factors={factors ?? []} combo={comboSpec} comboCosts={comboCosts} />
{detail.kind === "backtest" ? (
{detail.kind === "backtest" && !isComboArchive ? (
<StrategyDocCard
doc={docBody}
loading={false}
@@ -237,6 +249,66 @@ function condText(c: ResearchCondition): string {
* (含 `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,
@@ -377,11 +449,21 @@ 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="选股条件与交易执行依据">