字段库与单位: - `/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)。
176 lines
6.4 KiB
TypeScript
176 lines
6.4 KiB
TypeScript
"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>
|
||
)}
|
||
</>
|
||
);
|
||
}
|