让策略自由生长:投资计划配置编辑器的设计与实现

让策略自由生长:投资计划配置编辑器的设计与实现

让策略自由生长:投资计划配置编辑器的设计与实现

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 控件:

字段类型控件示例字段
布尔开关NSwitchenabled, auto_import_to_pool
百分比/数值NInputNumber + 后缀stop_loss_pct, max_position_pct
枚举NSelect + optionsmode(fixed/trailing/atr)
字符串NInputschedule_cron, daily_start
日期NDatePickerexpire_at
简单数组NDynamicInputblacklist, 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_windowdaily_startdaily_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 不渲染内部内容,避免了初始化时一次性创建大量控件。

浙ICP备2026022231号-1      浙公网安备33011002019439号