Files
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

176 lines
6.4 KiB
TypeScript
Raw Permalink 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.
"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>
)}
</>
);
}