让策略自由生长:投资计划配置编辑器的设计与实现
让策略自由生长:投资计划配置编辑器的设计与实现
10 个策略配置段,从后端数据类到前端折叠面板的完整落地
「量化实战手记」— 记录从想法到落地的真实开发历程
引言:一个完整的后端,一个空白的入口
投资计划模块的后端已经具备完整的策略配置体系:10 个强类型数据类,覆盖选股、开仓、止损、仓位管理等全部交易环节。PATCH 接口支持按段更新,浅合并逻辑确保每次只提交改动部分。
但用户看到的是什么?7 个 Tab 页,包含概览、股票池、持仓、交易记录……唯独没有一个地方可以修改策略参数。
后端准备好的 10 把钥匙,锁在柜子里,没人能拿到。这就是本文要解决的问题。
第一章:问题的全貌
先看看这 10 个配置段到底是什么。后端用 Python dataclass 定义,每个段对应交易流程中的一个决策环节:
每个配置段都有各自的字段类型:布尔开关、百分比数值、枚举选择、时间表达式、嵌套数组。这些字段在后端用 Python dataclass 定义,类型明确,默认值清晰。
问题是:前端完全没有对应的编辑入口。用户只能通过 API 或数据库直接修改配置,这对量化交易系统来说,等于把方向盘藏起来了。
原则:后端能力≠用户能力。一个功能只有被可视化,才算真正交付。
第二章:方案设计
面对 10 个配置段,有两种常见的 UI 方案:
方案 A:一个巨表单。把 10 个段的所有字段平铺到一个页面,底部一个「保存全部」按钮。优点是实现简单,缺点是改动一个止损比例就要提交整个配置,出错范围大。
方案 B:折叠面板 + 按段保存。10 个配置段用折叠面板(Collapse)分组展示,每段独立保存。优点是改动范围可控,用户心智负担低,缺点是组件稍复杂。
选择方案 B。原因很简单:策略配置的典型使用场景是「只改一个参数」。用户调整止损比例,不需要触碰选股策略。独立保存让每次提交的数据量最小,出错面也最小。
折叠面板按功能分三组:
| 分组 | 配置段 | 默认展开 |
|---|---|---|
| 交易规则 | screening / opening / adding / t_trading | 是 |
| 风险控制 | stop_loss / take_profit | 是 |
| 计划管理 | position_sizing / capital / termination / trading_window | 否 |
每组之间用分隔线做视觉区分,不嵌套 Collapse(避免层级过深)。
原则:让提交粒度匹配操作粒度。改一个参数就提交一个段,不要让用户为一次小改动承担大范围提交的风险。
第三章:核心实现
组件的核心结构:一个 NCollapse 包含 10 个 NCollapseItem,每个 Item 内部是 NForm + 「保存」按钮。
关键设计决策:表单控件映射。不同字段类型需要不同的 UI 控件:
| 字段类型 | 控件 | 示例字段 |
|---|---|---|
| 布尔开关 | NSwitch | enabled, auto_import_to_pool |
| 百分比/数值 | NInputNumber + 后缀 | stop_loss_pct, max_position_pct |
| 枚举 | NSelect + options | mode(fixed/trailing/atr) |
| 字符串 | NInput | schedule_cron, daily_start |
| 日期 | NDatePicker | expire_at |
| 简单数组 | NDynamicInput | blacklist, periods |
| 复合数组 | 动态行编辑器 | tier_thresholds, partial_steps |
| 字典 | 动态键值对编辑器 | tag_weights |
前 6 种是 Naive UI 的标准控件,直接映射即可。后两种需要额外处理。
复合数组:阶梯阈值编辑器
tier_thresholds 字段的结构是 [{threshold: 100, position: 0.3}, ...],表示资金阶梯和对应的仓位比例。这种结构无法用标准控件直接处理,需要自定义行编辑器。
// 阶梯阈值:每行一个 threshold + position
<div v-for="(_, i) in forms.capital.tier_thresholds" :key="i">
<NInputNumber v-model:value="forms.capital.tier_thresholds[i].threshold" />
<NInputNumber v-model:value="forms.capital.tier_thresholds[i].position" />
<NButton @click="removeItem(forms.capital.tier_thresholds, i)">删除</NButton>
</div>
<NButton @click="addItem(forms.capital.tier_thresholds, {threshold: 0, position: 0})">
添加阶梯
</NButton>
保存时,过滤掉空行(threshold 和 position 都为 0 的条目),避免无效数据入库。
字典:标签权重编辑器
tag_weights 是 {"医药": 1.5, "科技": 2.0} 这样的字典。Vue 的响应式系统对动态 key 的追踪不太好,所以用一个扁平的键值对数组作为中间态:
// 运行时状态
tagWeightEntries = reactive([{ key: "医药", value: 1.5 }, { key: "科技", value: 2.0 }])
// 保存时重建字典
const weights: Record<string, number> = {}
for (const e of tagWeightEntries) {
if (e.key.trim()) weights[e.key.trim()] = e.value
}
这个「扁平中间态 → 保存时重建」的模式,在处理动态字典时非常实用。
原则:当 UI 框架的响应式系统和数据结构不匹配时,引入中间态。运行时用框架友好的格式,保存时转换为目标格式。
第四章:保存流程的设计
每个配置段的保存流程是独立的:
关键代码只有几行。保存函数的核心:
async function save(section: string) {
const payload = buildPayload(section)
await planApi.updatePlan(planId, { [section]: payload })
message.success(`${SECTION_META[section].label} 已保存`)
}
planApi.updatePlan 内部调用 PATCH /api/plan/plans/:id,只提交当前段的数据。后端 PlanService.update() 做浅合并——取出当前计划,用新数据覆盖对应段,其他段原样保留。
这种设计的好处是爆炸半径可控。即使某个段的保存失败,其他段的配置完全不受影响。
默认值回退
另一个需要注意的点是默认值。新建的投资计划可能没有保存过某些配置段,API 返回的字段可能是 null 或缺失。组件需要在初始化时做回退:
const DEFAULTS = {
stop_loss: { enabled: true, stop_loss_pct: 5, mode: 'fixed', ... },
take_profit: { enabled: true, mode: 'fixed', ... },
...
}
// 初始化表单时合并默认值
const raw = plan.value?.stop_loss ?? {}
forms.stop_loss = { ...DEFAULTS.stop_loss, ...raw }
这个模式确保了未配置的字段有合理默认值,而不是一片空白。
第五章:表单控件映射的细节
10 个配置段,总共约 60 个字段。逐一手写每个表单控件是一个工作量问题。但也正是这种「体力活」,决定了用户体验的上限。
几个值得注意的映射细节:
百分比字段的后缀提示。stop_loss_pct 的值是 5,用户需要知道这是 5% 而不是 5 元。NInputNumber 的 #suffix 插槽可以显示单位:
<NInputNumber v-model:value="forms.stop_loss.stop_loss_pct" :min="0" :max="100" :step="0.5">
<template #suffix>%</template>
</NInputNumber>
枚举字段的选项映射。stop_loss.mode 有三种模式:fixed(固定比例)、trailing(追踪止损)、atr(ATR 倍数)。前端用 NSelect 展示中文标签:
<NSelect v-model:value="forms.stop_loss.mode"
:options="[
{ label: '固定比例', value: 'fixed' },
{ label: '追踪止损', value: 'trailing' },
{ label: 'ATR 倍数', value: 'atr' }
]"
/>
时间窗口的时段输入。trading_window 有 daily_start 和 daily_end 字段,格式是 "09:30" 这样的字符串。用 NInput 配合 placeholder 提示:
<NInput v-model:value="forms.trading_window.daily_start"
placeholder="09:30" />
这些细节看起来琐碎,但正是它们决定了配置页面是「能用」还是「好用」。
原则:表单控件不是数据类型的简单映射,而是用户意图的可视化。每一个后缀、placeholder、选项标签,都在帮用户减少认知负担。
总结
整个配置编辑器的实现,涉及两个核心文件的变更:
| 文件 | 操作 | 行数 |
|---|---|---|
| PlanStrategyConfig.vue | 新建 | ~500 |
| PlanDetail.vue | 修改(+1 Tab + import) | +3 |
后端零改动。API 层零改动。500 行代码,打通了 10 个策略配置段的完整编辑链路。
回头看,这个组件的设计可以提炼为三个核心决策:
1. 按段保存而非全量保存。匹配用户的实际操作习惯——通常只改一个参数。提交粒度匹配操作粒度。
2. 运行时中间态处理复杂结构。动态字典用扁平键值对数组做中间态,保存时重建。避免 Vue 响应式系统对动态 key 的追踪问题。
3. 默认值回退保证表单始终有值。新建计划可能缺少配置数据,默认值回退确保每个字段都有合理的初始值。
核心原则:好的配置界面不是数据的直接映射,而是用户意图的翻译器。让用户关注「我要什么」,而不是「数据结构长什么样」。
附录:技术速查表
如果你要实现类似的策略配置编辑器,以下要点供参考:
Naive UI 控件选择:
- 布尔 → NSwitch(直觉性最强)
- 数值 → NInputNumber(带 min/max/step 和后缀)
- 枚举 → NSelect(中文 label + 英文 value)
- 字符串 → NInput(配 placeholder 做格式提示)
- 日期 → NDatePicker(值转时间戳)
- 简单数组 → NDynamicInput(开箱即用)
- 复合数组 → 手动循环 + 增删按钮
- 字典 → 扁平键值对数组做中间态
按段保存的 API 设计:
// 前端:只提交当前段
PATCH /api/plan/plans/:id
Body: { "stop_loss": { "stop_loss_pct": 5, "mode": "fixed" } }
// 后端:浅合并
current = plan.to_dict()
current.update(request_body) // 只覆盖 stop_loss
plan = Plan.from_dict(current)
plan.save()
性能考虑:10 个折叠面板中只有展开的面板会渲染表单控件。Naive UI 的 Collapse 默认懒加载,未展开的 Item 不渲染内部内容,避免了初始化时一次性创建大量控件。