Files
one_divine_lot/docs/04-迭代记录/22-策略计算字段与策略tab静态化/技术实现方案.md
T
kyugao ec02a791ee 迭代22+23: 策略tab静态化 + 计算字段(数字/判定型)+ 网格超市信号字段
- R-027 静态化:策略恒为内置两项(不可增删改名,保留显隐/排序);设置页移除「策略分组」;
  字段配置入口迁到策略 tab 内「列设置」旁(FieldConfigDialog + schema-update 单策略写入)
- R-026 计算字段:自写公式引擎(中文变量、四则/括号/round·abs·min·max、缺值短路→—)+
  变量目录(行情/合约/持仓/自定义字段)+ strategy-positions 逐行现算(只读内存缓存,不落库、列只读)
- R-028 判定型:比较运算 + and/or + inferResultKind + 结果类型一致性校验 + 判定列 ✓/— 渲染;
  网格超市落地 可下空单=涨停价>基准值+网格大小、可下多单=跌停价<基准值-网格大小
- 变量选择改标签平铺(老师反馈);公式手册 docs/99-其他材料/计算字段公式说明.md
- 约束同步:产品约束-002/003/004/009/013/014、技术约束-014/015/022/023/024、UI约束-002/003/005/008
- 回归:新增 test-r026/test-r027,更新 r011/r013/r017,17 个脚本全绿;typecheck/build 通过
2026-09-10 14:05:06 +08:00

17 KiB
Raw Blame History

技术实现方案:22-策略计算字段与策略 tab 静态化

依据:R-027(重构·第一阶段)+ R-026(计算字段·第二阶段)+ PLAN-019 + UI交互设计.md(已定稿) 日期:2026-09-10 | 状态:方案稿(待老师过审后进入实现) 约束依据:技术约束-003(REST 直连)/011(回归独立数据目录)/012(策略定义存储)/015configSchema)、UI约束-004/005/006/007;本次修订与新增见 §7

0. 总览

阶段 需求 一句话 主要落点
R-027 策略 tab 静态化 + 字段配置入口迁到策略 tab 内 settings.js / api/strategies.js / SettingsSection.jsx / StrategyTab.jsx / StrategyFieldsEditor.jsx
R-026 新增「计算(formula)」字段类型:中文变量公式引用服务端缓存数据集现算 formula/(新)/ PositionManager 或 API 层附着 / StrategyTab.jsx / StrategyFieldsEditor.jsx

关键设计决策(本方案定)

  1. 计算在服务端 API 层附着strategy-positions 返回前),不改 PositionManager 的份额语义;
  2. 聚合口径只读内存缓存QuoteHub.quotes / instruments),即使 miss 也不做读穿透——strategy-positions 是高频读路径,不引入网络往返;行情新鲜度由 QuoteSync 5s 覆盖持仓 code 保证;
  3. 无 formula 字段的策略零开销(不取行情、不进引擎);
  4. 存储真相源单一:字段定义迁到 strategyFieldsstrategies 收窄为内置身份表(读取兼容旧定义)。

1. 第一阶段:静态化与入口迁移(R-027)

1.1 数据模型(src/settings.js

常量(新增/收窄)

/** 内置策略(静态,R-027):身份固定,不可增删改名 */
export const BUILTIN_STRATEGIES = [
  { id: 'grid-supermarket', name: '网格超市' },
  { id: 'manual-t', name: '手动做T' },
];
  • DEFAULT_STRATEGIES 退役(由 BUILTIN_STRATEGIES 取代,内部先行别名过渡)。

schema 变更

strategies: z.array(z.object({ id, name, configSchema })).default([]),  // 收窄:仅身份表,改为内置两项(兼容读取)
strategyFields: z.dict(z.array(fieldSchema)).default({}),                 // 新增:策略字段定义真相源 { [strategyId]: configSchema }
strategyColumns:                                                         // 不变
  • fieldSchema{ key, label, type(text|number|boolean|enum|formula), enum[], def, unit, formula, decimals }formula/decimals 为第二阶段新增,见 §2.1)。

读取归一化(单一真相源 + 旧数据兼容)

export function getStrategies(scope) {
  const v = scope?.get() ?? {};
  const legacy = new Map((v.strategies ?? []).map(s => [s.id, s.configSchema]));   // 旧:定义挂 strategies[]
  return BUILTIN_STRATEGIES.map(s => ({
    ...s,
    configSchema: Array.isArray(v.strategyFields?.[s.id])
      ? v.strategyFields[s.id]
      : (Array.isArray(legacy.get(s.id)) ? legacy.get(s.id) : []),               // 旧值回填视图(首次保存即落到 strategyFields
  }));
}
export function getStrategyFields(scope, strategyId) { /* 同上单策略版,未知 id → [] */ }
export async function updateStrategyFields(scope, strategyId, configSchema) {
  assertBuiltinStrategy(strategyId);                                            // 非内置 → 抛 strategy-not-found
  const cur = scope.get() ?? {};
  await scope.update({ ...cur, strategyFields: { ...(cur.strategyFields ?? {}), [strategyId]: normalizeFields(configSchema) } });
}
  • 兼容:存量 strategies[].configSchema(本机 = 网格超市 5 字段 / 手动做T 1 字段)读取时回填,前端表单提交全量 → 首次保存即写入 strategyFields无需迁移脚本
  • 自建策略(本机实测无):读取时忽略,其 strategy_holdings 行保留在库(不删数据)。

tabs 归一化(normalizeTabs 收窄)

  • 默认序列 = 内置 tab(3)+ 内置策略 tab(2);
  • 读取:按存量 tabsvisible/order 应用偏好,丢弃 kind='strategy'refId 不在 BUILTIN_STRATEGIES 中的条目(自建策略 tab 退役);
  • 新增策略 tab 条目若存量缺失(旧数据无策略 tab)→ 按默认序补齐;
  • 删除:appendStrategyTab / removeStrategyTab(不再有增删场景)。

退役清单(实施确认)addStrategy / removeStrategy / renameStrategy / updateStrategies / appendStrategyTab / removeStrategyTab / DEFAULT_STRATEGIES 已删除;generateStrategyId / PINYIN_MAP 保留QMT 连接 id 生成 generateQmtConnectionId 仍依赖,本轮不动)。

1.2 API 变更(src/api/strategies.js

端点 处理
strategies 保留(返回内置两项 + 各自 configSchema
strategies/add 退役
strategies/remove 退役
strategies/update 退役(整表写入不再需要;重命名随静态化取消)
strategies/schema-update 新增{ strategyId, configSchema } → 服务端校验(§2.5)→ updateStrategyFields → 返回该策略 { id, name, configSchema }
tabs / tabs/update 保留(显隐 + 排序为唯一可变项)
strategy-positions 保留 + 第二阶段附 computed(§2.4
formula/trial 新增(第二阶段,§2.5
  • STRATEGY_METHODS 集合同步增删;api/index.js 头部注释同步。
  • handleStrategy 解构补 marketHub, dataSource(计算字段取数用)。

1.3 设置页收敛(SettingsSection.jsx

  • 删除子 tab 项 { key:'strategies', label:'策略分组' } 与渲染分支、StrategyGroupSettings 组件、StrategyFieldsEditor 引入、新增/重命名/删除逻辑与删除确认弹窗(L208–436 区块);
  • 子 tab 仅剩 tabs / qmtactiveTab 初值维持 'tabs'

1.4 字段配置弹层(前端)

src/client/views/FieldConfigDialog.jsx(新增)

  • props{ strategyId, strategyName, configSchema, onSaved, onClose }
  • 形态:锚定弹层(width 520maxHeight 70vh、内部滚动),视觉沿用 ColumnSettingsPopover
  • 底部动作:[关闭](次) + [保存字段](主,saving 时 disabled);
  • 未保存改动dirty)时 关闭/外点/Esc → 二次确认「有未保存的字段改动,确定放弃?」(D-8)。

src/client/views/StrategyFieldsEditor.jsx(改造)

  • 由「设置页整表保存」改为「弹层内容 + 单策略保存」:onSave(fields) → 调 one-divine-lot/strategies/schema-update { strategyId, configSchema: fields }
  • 类型下拉增 计算(第二阶段);dirty 状态上报父弹层(用于放弃确认)。

src/client/views/StrategyTab.jsx(改造)

  • 顶栏「列设置」左侧新增「字段配置」按钮 → 打开 FieldConfigDialogonSavedload()(列与数据同步刷新);
  • configSchema 复用既有加载(L236239)。

2. 第二阶段:计算字段(R-026

2.1 字段模型扩展(settings.js fieldSchema

{ key, label, type: '…'|'formula', enum: [], def, unit: '',
  formula: z.string().default(''),     // 仅 type='formula':表达式串(中文变量)
  decimals: z.number().default(2) }    // 仅 type='formula':显示小数位 0-4
  • normalizeFieldsdecimals 夹取 04 整数;formula 去首尾空白;type='formula' 时忽略 def
  • 值不落库:strategy_holdings.values 与 formula 字段无关(读写路径零改动)。

2.2 公式引擎(src/formula/evaluator.js,纯函数零依赖)

validateFormula(expr, allowedVars)  { ok: true, vars: Set<string> }
                                   | { ok: false, error: { code: 'empty'|'syntax'|'unknown-var'|'unknown-func'|'arity', message, position } }
evaluateFormula(expr, ctx)          number | null      // ctx: { [中文变量名]: number|null }
evaluateFormulaCompiled(ast, ctx)   number | null      // 编译一次多行复用(性能)
  • 词法:数字(整数/小数)、标识符(\p{L} 起头,含中文,可含数字/下划线)、运算符 + - * / × ÷ ( ) ,×*÷/ 归一化);
  • 语法优先级:括号 > 一元负号 > * / / ÷ > + -;函数调用 name(arg, …)
  • 内置函数round(x[, n=0])abs(x)min(a, b, …)max(a, b, …)
  • 求值语义:引用的任一变量为 null/undefined/NaN → 整式返回 null(缺值短路,不产出 NaN);除零、非法运算、结果非有限 → null
  • 安全:自写解析,不使用 eval/Function(技术约束新增条目,§7)。

2.3 变量目录(src/formula/variables.js

export const VARIABLE_GROUPS = [
  { group: '行情', source: 'quote', items: [
    { name: '现价', field: 'lastPrice', desc: '最新成交价' },
    { name: '昨收', field: 'lastClose' }, { name: '今开', field: 'open' },
    { name: '最高', field: 'high' }, { name: '最低', field: 'low' },
    { name: '成交量', field: 'volume' }, { name: '成交额', field: 'amount' } ] },
  { group: '合约', source: 'instrument', items: [
    { name: '涨停价', field: 'upStopPrice' }, { name: '跌停价', field: 'downStopPrice' } ] },
  { group: '持仓', source: 'row', items: [
    { name: '份额', field: 'shares' }, { name: '成本价', field: 'avgPrice' }, { name: '最后成交价', field: 'lastTradePrice' } ] },
  { group: '自定义字段', source: 'values', items: [] },   // 运行时按策略非 formula 字段的 label 动态展开
];
export const BUILTIN_VAR_NAMES = new Set([...行情/合约/持仓 items  name]);
export function buildContext({ row, quote, instrument, fieldDefs })  { [中文名]: number|null }
  • 自定义字段变量名 = 字段 label;值取 row.values?.[key](缺省回退 def);非数字值(text/enum/boolean)→ Number(...) 失败即 null
  • 不收录 formula 字段(零循环依赖)。

2.4 计算服务与数据通路

src/formula/FormulaService.js(新增)

computeRows({ strategyId, rows, fieldDefs, marketHub })  rows 附加 computed: { [fieldKey]: number|null }
  1. type='formula' 字段 → 直接返回原 rows(零开销);
  2. codes = rows.map(r => r.code) → 行情只读内存缓存marketHub.quotes.get(code)、合约 marketHub.instruments.get(code)(与 api/market.js#projectQuote 同口径;不读穿透miss → 相关变量 null);
  3. 逐行 buildContext(含该策略非 formula 字段的值)→ 每个 formula 字段 evaluateFormulaCompiled 一次;
  4. 结果:computed[field.key] = number|null(引擎返回原始数值,格式化交前端)。

接线api/strategies.jsstrategy-positions 分支)

const rows = await manager.getStrategyPositions(args.strategyId);
return await formulaService.computeRows({ strategyId: args.strategyId, rows,
  fieldDefs: getStrategyFields(settings, args.strategyId), marketHub });
  • 历史持仓端点(strategy-holdings/history不计算:前端对历史行显示

2.5 API(校验与试算)

strategies/schema-update(含字段校验)

  • 结构校验:key 非空且唯一(同策略内)、label 非空且唯一(D-1)、type 合法;
  • formula 字段:validateFormula(formula, allowedVars)allowedVars = 内置变量名 本策略非 formula 字段的 label
  • 名称冲突:label ∈ BUILTIN_VAR_NAMES → 拒绝(variable-name-conflict);
  • 失败统一抛 { code:'field-validation', message }(前端 Toast + 公式框描红 + 文案)。

formula/trial(新增)

// args: { strategyId, formula, decimals?, unit? }
// 1) 校验公式;2) 取该策略首行份额 > 0 的持仓;3) 取缓存行情/合约 → buildContext → 求值
// → { code, name, value: number|null, reason?: 'no-holding'|'no-data'|null }

2.6 前端渲染(StrategyTab.jsx / ColumnSettingsPopover.jsx

  • 列取值:computed?.[key]nulltertiary);数值 → toFixed(decimals) 去尾零 + unit 拼接;
  • 表头:formula 字段列名前缀 ƒtertiarytitle="计算字段(只读)",列头/单元格 title = = <公式原文>
  • 只读:formula 列不挂 FieldCellEditor、不绑编辑 onClickfieldDef.type === 'formula' 分支直接渲染文本);
  • 列设置弹层:列名前缀 ƒnormalizeStrategyColumns 的字段列附 type 供 UI 判断;排序/显隐零改动);
  • 历史行:无 computed

3. 涉及文件与改动清单

src/settings.js                          # BUILTIN_STRATEGIES / strategyFields / normalizeTabs 收窄 / 退役 CRUD 辅助
src/api/strategies.js                    # 退役 add|remove|update;新增 schema-update、formula/trialstrategy-positions 附着 computed
src/api/index.js                         # 头部端点注释同步
src/formula/evaluator.js                 # 新增:词法/语法/求值/校验
src/formula/variables.js                 # 新增:变量目录 + buildContext
src/formula/FormulaService.js            # 新增:批量现算(缓存只读)
src/client/views/SettingsSection.jsx     # 移除「策略分组」子 tab
src/client/views/FieldConfigDialog.jsx   # 新增:字段配置弹层
src/client/views/StrategyFieldsEditor.jsx# 改造:弹层内容 + 单策略保存 + formula 分支(公式框/变量选择器/试算/小数位)
src/client/views/StrategyTab.jsx         # 顶栏「字段配置」按钮 + 计算列只读渲染
src/client/views/ColumnSettingsPopover.jsx # 列名 ƒ 前缀(仅文案)
scripts/test-r027-static-strategies.mjs  # 新增回归(第一阶段)
scripts/test-r026-formula-fields.mjs     # 新增回归(第二阶段:引擎 + 目录 + 校验 + 附着 + 试算)
scripts/test-r011-tabs.mjs               # 更新(策略 tab 静态化后断言调整)
scripts/test-r013-custom-fields.mjs      # 更新(改用 updateStrategyFields

4. 回归与验证

  • 回归脚本均用独立数据目录(ODL_TEST_DATA_DIR,技术约束-011),内存 mock settings scope(沿用 test-r013 惯例);
  • test-r027:内置策略恒两项;旧 settingsstrategies[].configSchema)读取回填;updateStrategyFields 单策略写入且不污染另一策略;非内置 strategyId → 拒绝;normalizeTabs 丢弃自建策略条目且保留 visible/orderstrategies/add|remove|update 端点已退役(unknown method);
  • test-r026:引擎(中文变量 / 优先级 / 括号 / 一元负 / 函数 arity / 语法错误位置 / 缺值短路 / 除零 → null);变量目录(三组内置名、自定义 label 动态、formula 字段不入目录);schema-update 校验(未知变量 / 名称冲突 / 重复 key / 重复 label);strategy-positions 附着 computed(有/无 formula 字段两条路径);formula/trial(正常 / 无持仓 / 缺数据);
  • pnpm typecheck + pnpm build 通过;
  • 老师人工验收(见 验收标准.md)。

5. 风险与取舍

取舍 / 缓解
现算开销 仅含 formula 字段的策略付出;行情只读内存(无网络);行数为数十级 → 毫秒内;未直接引入缓存
行情 miss 不读穿透 → 该行算式 (等 QuoteSync 下轮 5s 刷新);换取 strategy-positions 路径零网络
中文标识符 自写词法支持 \p{L};不用第三方库(均不支持中文变量)
旧数据 读取回填 + 首次保存落 strategyFields;不写迁移脚本、不删库数据
自建策略 读取忽略 + tab 丢弃;其持仓数据保留在库(不自动清份额),需要时人工处理
双份真相源风险 strategies[].configSchema 只读兼容、写路径唯一走 strategyFields(避免双写打架)

6. 实施顺序

  1. settings 层(常量 / strategyFields / tabs 归一化 / 退役)+ test-r027
  2. API 层(端点退役 + schema-update+ 设置页收敛 + 字段配置弹层 + test-r013 更新;
  3. 公式引擎 + 变量目录 + FormulaService + test-r026(引擎部分);
  4. schema-update 字段校验 + formula/trial + strategy-positions 附着;
  5. 前端 formula 分支(公式框/选择器/试算/小数位)+ 只读计算列;
  6. typecheck + build + 全量回归 → 老师人工验收 → 复盘。

7. 约束落地(实施时执行)

  • 修订:产品约束-002/003/004(标签体系 → 固定两策略 + 字段自定义)、产品约束-009(Tab 统一管理中去掉策略条目动态化)、UI约束-002(设置页子 tab 构成)、UI约束-003(Tab 设置策略行不再随 CRUD 变化)、UI约束-005(字段配置入口迁移 + 更正为「表格列化 + 单元格内联编辑」现状);
  • 新增:UI约束-008(计算字段表单 + 只读计算列:ƒ 标记 / 悬停公式 / 小数位 / )、产品功能约束(计算字段语义:不落库、只读、仅非公式变量)、技术方案约束(公式引擎自写零依赖不用 eval、变量目录单一入口、现算只读内存缓存)。