# 技术实现方案:22-策略计算字段与策略 tab 静态化 > 依据:R-027(重构·第一阶段)+ R-026(计算字段·第二阶段)+ PLAN-019 + `UI交互设计.md`(已定稿) > 日期:2026-09-10 | 状态:**方案稿(待老师过审后进入实现)** > 约束依据:技术约束-003(REST 直连)/011(回归独立数据目录)/012(策略定义存储)/015(configSchema)、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. 存储真相源单一:字段定义迁到 `strategyFields`,`strategies` 收窄为内置身份表(读取兼容旧定义)。 ## 1. 第一阶段:静态化与入口迁移(R-027) ### 1.1 数据模型(src/settings.js) **常量(新增/收窄)** ```js /** 内置策略(静态,R-027):身份固定,不可增删改名 */ export const BUILTIN_STRATEGIES = [ { id: 'grid-supermarket', name: '网格超市' }, { id: 'manual-t', name: '手动做T' }, ]; ``` - `DEFAULT_STRATEGIES` 退役(由 `BUILTIN_STRATEGIES` 取代,内部先行别名过渡)。 **schema 变更** ```js 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)。 **读取归一化(单一真相源 + 旧数据兼容)** ```js 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); - 读取:按存量 `tabs` 的 `visible/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` / `qmt`;`activeTab` 初值维持 `'tabs'`。 ### 1.4 字段配置弹层(前端) **`src/client/views/FieldConfigDialog.jsx`(新增)** - props:`{ strategyId, strategyName, configSchema, onSaved, onClose }`; - 形态:锚定弹层(`width 520`、`maxHeight 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`(改造)** - 顶栏「列设置」左侧新增「字段配置」按钮 → 打开 `FieldConfigDialog`;`onSaved` → `load()`(列与数据同步刷新); - configSchema 复用既有加载(L236–239)。 ## 2. 第二阶段:计算字段(R-026) ### 2.1 字段模型扩展(settings.js fieldSchema) ```js { key, label, type: '…'|'formula', enum: [], def, unit: '', formula: z.string().default(''), // 仅 type='formula':表达式串(中文变量) decimals: z.number().default(2) } // 仅 type='formula':显示小数位 0-4 ``` - `normalizeFields`:`decimals` 夹取 0–4 整数;`formula` 去首尾空白;`type='formula'` 时忽略 `def`; - 值不落库:`strategy_holdings.values` 与 formula 字段无关(读写路径零改动)。 ### 2.2 公式引擎(`src/formula/evaluator.js`,纯函数零依赖) ```js validateFormula(expr, allowedVars) → { ok: true, vars: Set } | { 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`) ```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`(新增)** ```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.js` 的 `strategy-positions` 分支) ```js 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`(新增)** ```js // 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]`;`null` → `—`(tertiary);数值 → `toFixed(decimals)` 去尾零 + `unit` 拼接; - 表头:formula 字段列名前缀 `ƒ`(tertiary,`title="计算字段(只读)"`),列头/单元格 `title` = `= <公式原文>`; - 只读:formula 列**不挂** `FieldCellEditor`、不绑编辑 onClick(`fieldDef.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/trial;strategy-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**:内置策略恒两项;旧 settings(strategies[].configSchema)读取回填;`updateStrategyFields` 单策略写入且不污染另一策略;非内置 strategyId → 拒绝;`normalizeTabs` 丢弃自建策略条目且保留 visible/order;`strategies/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、变量目录单一入口、现算只读内存缓存)。