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:
@@ -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="选股条件与交易执行依据">
|
||||
|
||||
Reference in New Issue
Block a user