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:
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user