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
+175
View File
@@ -0,0 +1,175 @@
"use client";
/**
* 公共配置(/settings)—— 全局唯一一份费率 / 滑点 / 最低佣金 / 复权口径 / 基准。
*
* 为什么单独一页:这些是「常规配置」,所有回测组合共用,不该在每个策略里重复填
* (用户目标第 1 条)。改这里会影响**之后**的回测;已跑过的归档不受影响
* (归档的 config_snapshot 固化了当时的成本/复权,可复现)。
*/
import { useEffect, useState } from "react";
import { apiGet, apiPut } from "@/lib/api";
import type { GlobalConfig } from "@/lib/types";
import { Banner, Btn, Card, Field, Loading, PageHeader, Pill } from "@/components/ui";
const DEFAULTS: GlobalConfig = {
commission_rate: 0.0003,
stamp_tax_rate: 0.0005,
slippage_rate: 0.001,
min_commission: 5,
price_adjustment: "hfq",
benchmark: "000300.SH",
};
export default function SettingsPage() {
const [cfg, setCfg] = useState<GlobalConfig | null>(null);
const [draft, setDraft] = useState<GlobalConfig>(DEFAULTS);
const [busy, setBusy] = useState(false);
const [err, setErr] = useState("");
const [msg, setMsg] = useState("");
useEffect(() => {
let alive = true;
apiGet<GlobalConfig>("/config")
.then((c) => {
if (!alive) return;
setCfg(c);
setDraft(c);
})
.catch((e: Error) => alive && setErr(e.message));
return () => {
alive = false;
};
}, []);
async function save() {
setBusy(true);
setErr("");
setMsg("");
try {
const saved = await apiPut<GlobalConfig>("/config", draft);
setCfg(saved);
setMsg("公共配置已保存(之后的回测组合将采用新值;已归档的结果不受影响)");
} catch (e) {
setErr((e as Error).message);
} finally {
setBusy(false);
}
}
// 只比较用户可编辑的字段:忽略 id / updated_at(后端回填,draft 里没有),
// 否则刚载入就会因 updated_at:null vs undefined 被判为「有改动」而误亮保存按钮。
const editable = (c: GlobalConfig) =>
JSON.stringify([c.commission_rate, c.stamp_tax_rate, c.slippage_rate, c.min_commission, c.price_adjustment, c.benchmark]);
const dirty = cfg ? editable(cfg) !== editable(draft) : false;
return (
<>
<PageHeader
title="公共配置"
sub="全局唯一的交易成本与行情口径 —— 所有回测组合共用。改动只影响之后的回测,已归档结果按各自快照复现。"
/>
{err ? <Banner tone="error">{err}</Banner> : null}
{msg ? <Banner tone="info">{msg}</Banner> : null}
{!cfg ? (
<Card><Loading label="读取公共配置…" /></Card>
) : (
<Card
icon="database"
title="交易成本与行情口径"
tools={
<div className="row" style={{ gap: 8 }}>
{dirty ? <Pill tone="warn">有未保存的修改</Pill> : null}
<Btn
variant="primary"
icon="check"
loading={busy}
disabled={busy || !dirty}
onClick={save}
>
保存配置
</Btn>
</div>
}
>
<div className="form-grid">
<Field label="佣金率" hint="如 0.0003 = 万三;买卖双向收取">
<input
className="input mono"
type="number"
inputMode="decimal"
step="0.00001"
min={0}
max={0.01}
value={draft.commission_rate}
onChange={(e) => setDraft({ ...draft, commission_rate: Number(e.target.value) })}
/>
</Field>
<Field label="印花税率" hint="仅卖出收取;A 股 0.0005(2023-08 起减半)">
<input
className="input mono"
type="number"
inputMode="decimal"
step="0.00001"
min={0}
max={0.01}
value={draft.stamp_tax_rate}
onChange={(e) => setDraft({ ...draft, stamp_tax_rate: Number(e.target.value) })}
/>
</Field>
<Field label="滑点率" hint="按成交价比例估计冲击成本;0.0005~0.001 较常见">
<input
className="input mono"
type="number"
inputMode="decimal"
step="0.0001"
min={0}
max={0.05}
value={draft.slippage_rate}
onChange={(e) => setDraft({ ...draft, slippage_rate: Number(e.target.value) })}
/>
</Field>
<Field label="最低佣金(元/笔)" hint="单笔佣金下限,小额单影响显著;常见 5 元">
<input
className="input mono"
type="number"
inputMode="decimal"
step={1}
min={0}
value={draft.min_commission}
onChange={(e) => setDraft({ ...draft, min_commission: Number(e.target.value) })}
/>
</Field>
<Field label="复权口径" hint="高股息类建议后复权 hfq(把分红再投资计入收益)">
<select
className="input"
value={draft.price_adjustment}
onChange={(e) =>
setDraft({ ...draft, price_adjustment: e.target.value as GlobalConfig["price_adjustment"] })
}
>
<option value="hfq">后复权 hfq</option>
<option value="qfq">前复权 qfq</option>
<option value="none">不复权 none</option>
</select>
</Field>
<Field label="对照基准" hint="仅用于展示对比,不参与交易">
<input
className="input mono"
value={draft.benchmark}
onChange={(e) => setDraft({ ...draft, benchmark: e.target.value })}
/>
</Field>
</div>
<div className="hint" style={{ marginTop: 12 }}>
费率以**小数**填写(0.0003 = 万三,不是 0.03%)。这里的值是全局默认;每个回测组合运行时
会把当时的成本/复权**快照**进归档,所以事后改这里不会改变历史结果的数字。
</div>
</Card>
)}
</>
);
}